172 files changed
+4888
-3633
lib/bootstrap/bootstrap_screen.dart
+87
-76
@@ -57,100 +57,111 @@ class BootstrapScreen extends StatelessWidget {
57
GlobalMaterialLocalizations.delegate,
58
GlobalWidgetsLocalizations.delegate,
59
],
60
- child: Builder(builder: (context) {
61
- final l10n = AppLocalizations.of(context)!;
62
- return Directionality(
63
- textDirection: locale.languageCode.startsWith('ar')
64
- ? TextDirection.rtl
65
- : TextDirection.ltr,
66
- child: Material(
67
- color: isDark ? AppColors.darkBackground : AppColors.lightBackground,
68
- child: SafeArea(
69
- child: Center(
70
- child: Padding(
71
- padding: const EdgeInsets.symmetric(horizontal: 32),
72
- child: Column(
73
- mainAxisSize: MainAxisSize.min,
74
- children: [
75
- ClipRRect(
76
- borderRadius: BorderRadius.circular(12),
77
- child: Image.asset(
78
- 'assets/images/logo.webp',
79
- width: 72,
80
- height: 72,
81
- ),
82
- ),
83
- const SizedBox(height: 24),
84
- Text(
85
- l10n.app_name,
86
- style: TextStyle(
87
- fontSize: 28,
88
- fontWeight: FontWeight.bold,
89
- color: isDark
90
- ? AppColors.darkPrimaryText
91
- : AppColors.lightPrimaryText,
92
- letterSpacing: 1,
93
- ),
94
- ),
95
- const SizedBox(height: 48),
96
- if (state.stage != BootstrapStage.error) ...[
97
- LoadingAnimationWidget.fourRotatingDots(
98
- size: 32,
99
- color: isDark
100
- ? AppColors.darkPrimaryText
101
- : AppColors.lightPrimaryText,
60
+ child: Builder(
61
+ builder: (context) {
62
+ final l10n = AppLocalizations.of(context)!;
63
+ return Directionality(
64
+ textDirection: locale.languageCode.startsWith('ar')
65
+ ? TextDirection.rtl
66
+ : TextDirection.ltr,
67
+ child: Material(
68
+ color: isDark
69
+ ? AppColors.darkBackground
70
+ : AppColors.lightBackground,
71
+ child: SafeArea(
72
+ child: Center(
73
+ child: Padding(
74
+ padding: const EdgeInsets.symmetric(horizontal: 32),
75
+ child: Column(
76
+ mainAxisSize: MainAxisSize.min,
77
+ children: [
78
+ ClipRRect(
79
+ borderRadius: BorderRadius.circular(12),
80
+ child: Image.asset(
81
+ 'assets/images/logo.webp',
82
+ width: 72,
83
+ height: 72,
84
+ ),
85
),
86
const SizedBox(height: 24),
87
Text(
105
- _stageMessage(l10n),
106
- textAlign: TextAlign.center,
88
+ l10n.app_name,
89
style: TextStyle(
108
- fontSize: 14,
90
+ fontSize: 28,
91
+ fontWeight: FontWeight.bold,
92
color: isDark
110
- ? AppColors.darkMutedText
111
- : AppColors.lightMutedText,
93
+ ? AppColors.darkPrimaryText
94
+ : AppColors.lightPrimaryText,
95
+ letterSpacing: 1,
96
),
97
),
114
- ] else ...[
115
- HugeIcon(icon: HugeIcons.strokeRoundedInformationCircle, size: 40, color: AppColors.error),
116
- const SizedBox(height: 16),
117
- Text(
118
- l10n.something_went_wrong,
119
- style: TextStyle(
120
- fontSize: 16,
121
- fontWeight: FontWeight.w600,
98
+ const SizedBox(height: 48),
99
+ if (state.stage != BootstrapStage.error) ...[
100
+ LoadingAnimationWidget.fourRotatingDots(
101
+ size: 32,
102
color: isDark
103
? AppColors.darkPrimaryText
104
: AppColors.lightPrimaryText,
105
),
126
- ),
127
- const SizedBox(height: 8),
128
- Text(
129
- state.error?.toString() ?? l10n.unknown_error,
130
- textAlign: TextAlign.center,
131
- style: TextStyle(
132
- fontSize: 13,
133
- color: isDark
134
- ? AppColors.darkMutedText
135
- : AppColors.lightMutedText,
106
+ const SizedBox(height: 24),
107
+ Text(
108
+ _stageMessage(l10n),
109
+ textAlign: TextAlign.center,
110
+ style: TextStyle(
111
+ fontSize: 14,
112
+ color: isDark
113
+ ? AppColors.darkMutedText
114
+ : AppColors.lightMutedText,
115
+ ),
116
),
137
- ),
138
- const SizedBox(height: 24),
139
- if (onRetry != null)
140
- ElevatedButton.icon(
141
- onPressed: onRetry,
142
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedRefresh, size: 18),
143
- label: Text(l10n.retry),
117
+ ] else ...[
118
+ HugeIcon(
119
+ icon: HugeIcons.strokeRoundedInformationCircle,
120
+ size: 40,
121
+ color: AppColors.error,
122
+ ),
123
+ const SizedBox(height: 16),
124
+ Text(
125
+ l10n.something_went_wrong,
126
+ style: TextStyle(
127
+ fontSize: 16,
128
+ fontWeight: FontWeight.w600,
129
+ color: isDark
130
+ ? AppColors.darkPrimaryText
131
+ : AppColors.lightPrimaryText,
132
+ ),
133
+ ),
134
+ const SizedBox(height: 8),
135
+ Text(
136
+ state.error?.toString() ?? l10n.unknown_error,
137
+ textAlign: TextAlign.center,
138
+ style: TextStyle(
139
+ fontSize: 13,
140
+ color: isDark
141
+ ? AppColors.darkMutedText
142
+ : AppColors.lightMutedText,
143
+ ),
144
),
145
+ const SizedBox(height: 24),
146
+ if (onRetry != null)
147
+ ElevatedButton.icon(
148
+ onPressed: onRetry,
149
+ icon: const HugeIcon(
150
+ icon: HugeIcons.strokeRoundedRefresh,
151
+ size: 18,
152
+ ),
153
+ label: Text(l10n.retry),
154
+ ),
155
+ ],
156
],
146
- ],
157
+ ),
158
),
159
),
160
),
161
),
151
- ),
152
- );
153
- }),
162
+ );
163
+ },
164
+ ),
165
);
166
}
167
}
lib/core/components/app_button.dart
+1
-1
@@ -162,4 +162,4 @@ class AppLoadingIndicator extends StatelessWidget {
162
),
163
);
164
}
165
-}
\ No newline at end of file
165
+}
lib/core/components/folder_filter_bar.dart
+11
-11
@@ -31,7 +31,7 @@ class FolderFilterBar extends StatelessWidget {
31
/// (long-press on touch devices, right-click on desktop). When `null`,
32
/// folder chips are not interactive for management actions.
33
final void Function(FolderFilterItem folder, Offset globalPosition)?
34
- onFolderAction;
34
+ onFolderAction;
35
final bool isLoading;
36
final bool showCreateFolder;
37
@@ -74,8 +74,8 @@ class FolderFilterBar extends StatelessWidget {
74
? null
75
: (globalPos) => onFolderAction!(folder, globalPos),
76
child: FilterChip(
77
- avatar: HugeIcon(icon:
78
- HugeIcons.strokeRoundedFolder01,
77
+ avatar: HugeIcon(
78
+ icon: HugeIcons.strokeRoundedFolder01,
79
size: 16,
80
color: selectedFolderId == folder.id
81
? theme.colorScheme.onSecondaryContainer
@@ -92,8 +92,8 @@ class FolderFilterBar extends StatelessWidget {
92
if (showCreateFolder) ...[
93
const SizedBox(width: 6),
94
ActionChip(
95
- avatar: HugeIcon(icon:
96
- HugeIcons.strokeRoundedFolderAdd,
95
+ avatar: HugeIcon(
96
+ icon: HugeIcons.strokeRoundedFolderAdd,
97
size: 16,
98
color: theme.colorScheme.primary,
99
),
@@ -134,18 +134,18 @@ class SystemFolderFilterChip extends StatelessWidget {
134
final theme = Theme.of(context);
135
final bgColor = selected
136
? (outlined
137
- ? theme.colorScheme.surfaceContainerHighest
138
- : theme.colorScheme.secondaryContainer)
137
+ ? theme.colorScheme.surfaceContainerHighest
138
+ : theme.colorScheme.secondaryContainer)
139
: Colors.transparent;
140
final borderColor = outlined
141
? (isDark ? const Color(0xFF444444) : const Color(0xFFCCCCCC))
142
: (selected
143
- ? theme.colorScheme.secondaryContainer
144
- : (isDark ? const Color(0xFF333333) : const Color(0xFFE0E0E0)));
143
+ ? theme.colorScheme.secondaryContainer
144
+ : (isDark ? const Color(0xFF333333) : const Color(0xFFE0E0E0)));
145
146
return FilterChip(
147
- avatar: HugeIcon(icon:
148
- icon,
147
+ avatar: HugeIcon(
148
+ icon: icon,
149
size: 16,
150
color: selected
151
? theme.colorScheme.onSecondaryContainer
lib/core/components/folder_management_dialogs.dart
+1
-1
@@ -115,4 +115,4 @@ Future<bool> showDeleteFolderConfirmation(
115
},
116
);
117
return result ?? false;
118
-}
\ No newline at end of file
118
+}
lib/core/components/list_filter_button.dart
+8
-12
@@ -2,11 +2,7 @@ import 'package:hugeicons/hugeicons.dart';
2
import 'package:flutter/material.dart';
3
4
class ListFilterOption<T> {
5
- const ListFilterOption({
6
- required this.value,
7
- required this.label,
8
- this.icon,
9
- });
5
+ const ListFilterOption({required this.value, required this.label, this.icon});
6
7
final T value;
8
final String label;
@@ -46,8 +42,8 @@ class ListFilterButton<T> extends StatelessWidget {
42
icon: Badge(
43
isLabelVisible: _hasActiveFilter,
44
smallSize: 8,
49
- child: HugeIcon(icon:
50
- icon,
45
+ child: HugeIcon(
46
+ icon: icon,
47
color: _hasActiveFilter
48
? theme.colorScheme.primary
49
: theme.iconTheme.color,
@@ -68,8 +64,8 @@ class ListFilterButton<T> extends StatelessWidget {
64
...options.map(
65
(option) => ListTile(
66
leading: option.icon != null
71
- ? HugeIcon(icon:
72
- option.icon!,
67
+ ? HugeIcon(
68
+ icon: option.icon!,
69
color: selected == option.value
70
? Theme.of(ctx).colorScheme.primary
71
: null,
@@ -77,8 +73,8 @@ class ListFilterButton<T> extends StatelessWidget {
73
: null,
74
title: Text(option.label),
75
trailing: selected == option.value
80
- ? HugeIcon(icon:
81
- HugeIcons.strokeRoundedTick01,
76
+ ? HugeIcon(
77
+ icon: HugeIcons.strokeRoundedTick01,
78
color: Theme.of(ctx).colorScheme.primary,
79
)
80
: null,
@@ -95,4 +91,4 @@ class ListFilterButton<T> extends StatelessWidget {
91
},
92
);
93
}
98
-}
\ No newline at end of file
94
+}
lib/core/constants/app_constants.dart
+2
-1
@@ -1,7 +1,8 @@
1
class AppConstants {
2
static const String appName = 'LocalMind';
3
static const String appTagline = 'Your AI. Your Device. Your Rules.';
4
- static const String version = '1.0.0'; // Fallback; prefer PackageInfo.version at runtime
4
+ static const String version =
5
+ '1.0.0'; // Fallback; prefer PackageInfo.version at runtime
6
7
static const double defaultTemperature = 0.7;
8
static const double defaultTopP = 0.9;
lib/core/device/device_memory_service.dart
+6
-2
@@ -28,7 +28,9 @@ class DeviceMemoryInfo {
28
29
class DeviceMemoryService {
30
final DeviceInfoPlugin _deviceInfo = DeviceInfoPlugin();
31
- static const MethodChannel _memoryChannel = MethodChannel('localmind/device_memory');
31
+ static const MethodChannel _memoryChannel = MethodChannel(
32
+ 'localmind/device_memory',
33
+ );
34
35
Future<DeviceMemoryInfo> getMemoryInfo() async {
36
int totalMb = 0;
@@ -36,7 +38,9 @@ class DeviceMemoryService {
38
39
if (Platform.isAndroid) {
40
try {
39
- final memory = await _memoryChannel.invokeMapMethod<String, dynamic>('getMemoryInfo');
41
+ final memory = await _memoryChannel.invokeMapMethod<String, dynamic>(
42
+ 'getMemoryInfo',
43
+ );
44
if (memory != null) {
45
totalMb = (memory['totalMemoryMb'] as num?)?.toInt() ?? 0;
46
availableMb = (memory['availableMemoryMb'] as num?)?.toInt() ?? 0;
lib/core/providers/service_providers.dart
+26
-15
@@ -30,7 +30,10 @@ class RedirectInterceptor extends Interceptor {
30
final location = response.headers.value('location');
31
if (location != null) {
32
try {
33
- final redirectedResponse = await _performRedirect(response.requestOptions, location);
33
+ final redirectedResponse = await _performRedirect(
34
+ response.requestOptions,
35
+ location,
36
+ );
37
return handler.resolve(redirectedResponse);
38
} catch (_) {
39
return handler.next(response);
@@ -43,31 +46,41 @@ class RedirectInterceptor extends Interceptor {
46
@override
47
void onError(DioException err, ErrorInterceptorHandler handler) async {
48
final response = err.response;
46
- if (response != null && (response.statusCode == 307 || response.statusCode == 308)) {
49
+ if (response != null &&
50
+ (response.statusCode == 307 || response.statusCode == 308)) {
51
final location = response.headers.value('location');
52
if (location != null) {
53
try {
50
- final redirectedResponse = await _performRedirect(err.requestOptions, location);
54
+ final redirectedResponse = await _performRedirect(
55
+ err.requestOptions,
56
+ location,
57
+ );
58
return handler.resolve(redirectedResponse);
59
} catch (e) {
60
if (e is DioException) {
61
return handler.next(e);
62
}
56
- return handler.next(DioException(
57
- requestOptions: err.requestOptions,
58
- error: e,
59
- type: DioExceptionType.unknown,
60
- ));
63
+ return handler.next(
64
+ DioException(
65
+ requestOptions: err.requestOptions,
66
+ error: e,
67
+ type: DioExceptionType.unknown,
68
+ ),
69
+ );
70
}
71
}
72
}
73
super.onError(err, handler);
74
}
75
67
- Future<Response<dynamic>> _performRedirect(RequestOptions requestOptions, String location) async {
76
+ Future<Response<dynamic>> _performRedirect(
77
+ RequestOptions requestOptions,
78
+ String location,
79
+ ) async {
80
var redirectUrl = location;
81
final originalUri = requestOptions.uri;
70
- if (!redirectUrl.startsWith('http://') && !redirectUrl.startsWith('https://')) {
82
+ if (!redirectUrl.startsWith('http://') &&
83
+ !redirectUrl.startsWith('https://')) {
84
final baseUri = Uri.parse(requestOptions.path);
85
redirectUrl = baseUri.resolve(redirectUrl).toString();
86
}
@@ -88,14 +101,13 @@ class RedirectInterceptor extends Interceptor {
101
// response (e.g. from a compromised or misconfigured server) must not
102
// be able to exfiltrate them to an arbitrary host.
103
final redirectUri = Uri.parse(redirectUrl);
91
- final sameOrigin = redirectUri.scheme == originalUri.scheme &&
104
+ final sameOrigin =
105
+ redirectUri.scheme == originalUri.scheme &&
106
redirectUri.host == originalUri.host &&
107
redirectUri.port == originalUri.port;
108
final headers = Map<String, dynamic>.from(requestOptions.headers);
109
if (!sameOrigin) {
96
- headers.removeWhere(
97
- (key, _) => key.toLowerCase() == 'authorization',
98
- );
110
+ headers.removeWhere((key, _) => key.toLowerCase() == 'authorization');
111
}
112
113
return _dio.request(
@@ -112,4 +124,3 @@ class RedirectInterceptor extends Interceptor {
124
);
125
}
126
}
115
-
lib/core/services/crash_report_service.dart
+4
-15
@@ -50,10 +50,7 @@ class CrashReport {
50
/// Issue body markdown for GitHub. Built lazily when accessed so URL
51
/// generation remains synchronous and side-effect-free.
52
String get markdownBody {
53
- final stack = _truncate(
54
- _sanitizePaths(stackTrace.toString()),
55
- 3000,
56
- );
53
+ final stack = _truncate(_sanitizePaths(stackTrace.toString()), 3000);
54
final cleanStack = _stripBackticks(stack);
55
final ts = timestamp.toUtc().toIso8601String();
56
@@ -81,9 +78,7 @@ class CrashReport {
78
..writeln()
79
..writeln('```text')
80
..writeln(
84
- _stripBackticks(
85
- _truncate(_sanitizePaths(errorWidgetPayload!), 2000),
86
- ),
81
+ _stripBackticks(_truncate(_sanitizePaths(errorWidgetPayload!), 2000)),
82
)
83
..writeln('```');
84
}
@@ -129,14 +124,8 @@ class CrashReport {
124
/// absolute paths while preserving the structure of the stack trace.
125
String _sanitizePaths(String input) {
126
return input
132
- .replaceAllMapped(
133
- RegExp(r'/Users/[^/\s]+'),
134
- (_) => '/Users/<user>',
135
- )
136
- .replaceAllMapped(
137
- RegExp(r'/home/[^/\s]+'),
138
- (_) => '/home/<user>',
139
- )
127
+ .replaceAllMapped(RegExp(r'/Users/[^/\s]+'), (_) => '/Users/<user>')
128
+ .replaceAllMapped(RegExp(r'/home/[^/\s]+'), (_) => '/home/<user>')
129
.replaceAllMapped(
130
RegExp(r'/data/user/[^/\s]+'),
131
(_) => '/data/user/<user>',
lib/core/services/export_choice_dialog.dart
+3
-3
@@ -42,9 +42,9 @@ Future<void> showExportChoiceDialog(
42
if (choice == _ExportChoice.clipboard) {
43
await Clipboard.setData(ClipboardData(text: content));
44
if (context.mounted) {
45
- ScaffoldMessenger.of(context).showSnackBar(
46
- SnackBar(content: Text(l10n.copied_to_clipboard)),
47
- );
45
+ ScaffoldMessenger.of(
46
+ context,
47
+ ).showSnackBar(SnackBar(content: Text(l10n.copied_to_clipboard)));
48
}
49
} else {
50
await ShareService.shareText(content, subject: subject);
lib/core/storage/entities.dart
+11
-4
@@ -49,7 +49,7 @@ class ServerEntity {
49
this.isDefault = false,
50
required this.createdAt,
51
required this.lastConnectedAt,
52
- required this.statusIndex,
52
+ required this.statusIndex,
53
this.iconName,
54
this.pathPrefix,
55
this.availableRamGb,
@@ -314,7 +314,6 @@ class ConversationEntity {
314
totalTokenCount: totalTokenCount,
315
);
316
}
317
-
317
}
318
319
@Entity()
@@ -557,7 +556,11 @@ class MessageEntity {
556
final decoded = jsonDecode(toolCallsJson!);
557
if (decoded is List) {
558
return decoded
560
- .map((e) => ToolCallData.fromMap(Map<String, dynamic>.from(e as Map)))
559
+ .map(
560
+ (e) => ToolCallData.fromMap(
561
+ Map<String, dynamic>.from(e as Map),
562
+ ),
563
+ )
564
.toList();
565
}
566
return null;
@@ -575,7 +578,11 @@ class MessageEntity {
578
final decoded = jsonDecode(toolEventsJson!);
579
if (decoded is List) {
580
return decoded
578
- .map((e) => ToolEvent.fromMap(Map<String, dynamic>.from(e as Map)))
581
+ .map(
582
+ (e) => ToolEvent.fromMap(
583
+ Map<String, dynamic>.from(e as Map),
584
+ ),
585
+ )
586
.toList();
587
}
588
return null;
lib/core/utils/bpe_decoder.dart
+258
-42
@@ -3,10 +3,10 @@ import 'dart:convert';
3
class SimpleStringSink implements Sink<String> {
4
final void Function(String) onAdd;
5
SimpleStringSink(this.onAdd);
6
-
6
+
7
@override
8
void add(String data) => onAdd(data);
9
-
9
+
10
@override
11
void close() {}
12
}
@@ -48,7 +48,7 @@ class BpeDecoder {
48
bytes.add(byte);
49
}
50
}
51
-
51
+
52
final previousLength = _decoded.length;
53
try {
54
_utf8Sink.add(bytes);
@@ -73,43 +73,259 @@ class BpeDecoder {
73
}
74
75
const Map<int, int> _bpeToByte = {
76
- 33: 33, 34: 34, 35: 35, 36: 36, 37: 37, 38: 38, 39: 39, 40: 40,
77
- 41: 41, 42: 42, 43: 43, 44: 44, 45: 45, 46: 46, 47: 47, 48: 48,
78
- 49: 49, 50: 50, 51: 51, 52: 52, 53: 53, 54: 54, 55: 55, 56: 56,
79
- 57: 57, 58: 58, 59: 59, 60: 60, 61: 61, 62: 62, 63: 63, 64: 64,
80
- 65: 65, 66: 66, 67: 67, 68: 68, 69: 69, 70: 70, 71: 71, 72: 72,
81
- 73: 73, 74: 74, 75: 75, 76: 76, 77: 77, 78: 78, 79: 79, 80: 80,
82
- 81: 81, 82: 82, 83: 83, 84: 84, 85: 85, 86: 86, 87: 87, 88: 88,
83
- 90: 90, 91: 91, 92: 92, 93: 93, 94: 94, 95: 95, 96: 96, 97: 97,
84
- 98: 98, 99: 99, 100: 100, 101: 101, 102: 102, 103: 103, 104: 104,
85
- 105: 105, 106: 106, 107: 107, 108: 108, 109: 109, 110: 110,
86
- 111: 111, 112: 112, 113: 113, 114: 114, 115: 115, 116: 116,
87
- 117: 117, 118: 118, 119: 119, 120: 120, 121: 121, 122: 122,
88
- 123: 123, 124: 124, 125: 125, 126: 126, 161: 161, 162: 162,
89
- 163: 163, 164: 164, 165: 165, 166: 166, 167: 167, 168: 168,
90
- 169: 169, 170: 170, 171: 171, 172: 172, 174: 174, 175: 175,
91
- 176: 176, 177: 177, 178: 178, 179: 179, 180: 180, 181: 181,
92
- 182: 182, 183: 183, 184: 184, 185: 185, 186: 186, 187: 187,
93
- 188: 188, 189: 189, 190: 190, 191: 191, 192: 192, 193: 193,
94
- 194: 194, 195: 195, 196: 196, 197: 197, 198: 198, 199: 199,
95
- 200: 200, 201: 201, 202: 202, 203: 203, 204: 204, 205: 205,
96
- 206: 206, 207: 207, 208: 208, 209: 209, 210: 210, 211: 211,
97
- 212: 212, 213: 213, 214: 214, 215: 215, 216: 216, 217: 217,
98
- 218: 218, 219: 219, 220: 220, 221: 221, 222: 222, 223: 223,
99
- 224: 224, 225: 225, 226: 226, 227: 227, 228: 228, 229: 229,
100
- 230: 230, 231: 231, 232: 232, 233: 233, 234: 234, 235: 235,
101
- 236: 236, 237: 237, 238: 238, 239: 239, 240: 240, 241: 241,
102
- 242: 242, 243: 243, 244: 244, 245: 245, 246: 246, 247: 247,
103
- 248: 248, 249: 249, 250: 250, 251: 251, 252: 252, 253: 253,
104
- 254: 255, 255: 255, 256: 0, 257: 1, 258: 2, 259: 3, 260: 4,
105
- 261: 5, 262: 6, 263: 7, 264: 8, 265: 9, 266: 10, 267: 11, 268: 12,
106
- 269: 13, 270: 14, 271: 15, 272: 16, 273: 17, 274: 18, 275: 19,
107
- 276: 20, 277: 21, 278: 22, 279: 23, 280: 24, 281: 25, 282: 26,
108
- 283: 27, 284: 28, 285: 29, 286: 30, 287: 31, 288: 32, 289: 127,
109
- 290: 128, 291: 129, 292: 130, 293: 131, 294: 132, 295: 133,
110
- 296: 134, 297: 135, 298: 136, 299: 137, 300: 138, 301: 139,
111
- 302: 140, 303: 141, 304: 142, 305: 143, 306: 144, 307: 145,
112
- 308: 146, 309: 147, 310: 148, 311: 149, 312: 150, 313: 151,
113
- 314: 152, 315: 153, 316: 154, 317: 155, 318: 156, 319: 157,
114
- 320: 158, 321: 159, 322: 160, 323: 173,
76
+ 33: 33,
77
+ 34: 34,
78
+ 35: 35,
79
+ 36: 36,
80
+ 37: 37,
81
+ 38: 38,
82
+ 39: 39,
83
+ 40: 40,
84
+ 41: 41,
85
+ 42: 42,
86
+ 43: 43,
87
+ 44: 44,
88
+ 45: 45,
89
+ 46: 46,
90
+ 47: 47,
91
+ 48: 48,
92
+ 49: 49,
93
+ 50: 50,
94
+ 51: 51,
95
+ 52: 52,
96
+ 53: 53,
97
+ 54: 54,
98
+ 55: 55,
99
+ 56: 56,
100
+ 57: 57,
101
+ 58: 58,
102
+ 59: 59,
103
+ 60: 60,
104
+ 61: 61,
105
+ 62: 62,
106
+ 63: 63,
107
+ 64: 64,
108
+ 65: 65,
109
+ 66: 66,
110
+ 67: 67,
111
+ 68: 68,
112
+ 69: 69,
113
+ 70: 70,
114
+ 71: 71,
115
+ 72: 72,
116
+ 73: 73,
117
+ 74: 74,
118
+ 75: 75,
119
+ 76: 76,
120
+ 77: 77,
121
+ 78: 78,
122
+ 79: 79,
123
+ 80: 80,
124
+ 81: 81,
125
+ 82: 82,
126
+ 83: 83,
127
+ 84: 84,
128
+ 85: 85,
129
+ 86: 86,
130
+ 87: 87,
131
+ 88: 88,
132
+ 90: 90,
133
+ 91: 91,
134
+ 92: 92,
135
+ 93: 93,
136
+ 94: 94,
137
+ 95: 95,
138
+ 96: 96,
139
+ 97: 97,
140
+ 98: 98,
141
+ 99: 99,
142
+ 100: 100,
143
+ 101: 101,
144
+ 102: 102,
145
+ 103: 103,
146
+ 104: 104,
147
+ 105: 105,
148
+ 106: 106,
149
+ 107: 107,
150
+ 108: 108,
151
+ 109: 109,
152
+ 110: 110,
153
+ 111: 111,
154
+ 112: 112,
155
+ 113: 113,
156
+ 114: 114,
157
+ 115: 115,
158
+ 116: 116,
159
+ 117: 117,
160
+ 118: 118,
161
+ 119: 119,
162
+ 120: 120,
163
+ 121: 121,
164
+ 122: 122,
165
+ 123: 123,
166
+ 124: 124,
167
+ 125: 125,
168
+ 126: 126,
169
+ 161: 161,
170
+ 162: 162,
171
+ 163: 163,
172
+ 164: 164,
173
+ 165: 165,
174
+ 166: 166,
175
+ 167: 167,
176
+ 168: 168,
177
+ 169: 169,
178
+ 170: 170,
179
+ 171: 171,
180
+ 172: 172,
181
+ 174: 174,
182
+ 175: 175,
183
+ 176: 176,
184
+ 177: 177,
185
+ 178: 178,
186
+ 179: 179,
187
+ 180: 180,
188
+ 181: 181,
189
+ 182: 182,
190
+ 183: 183,
191
+ 184: 184,
192
+ 185: 185,
193
+ 186: 186,
194
+ 187: 187,
195
+ 188: 188,
196
+ 189: 189,
197
+ 190: 190,
198
+ 191: 191,
199
+ 192: 192,
200
+ 193: 193,
201
+ 194: 194,
202
+ 195: 195,
203
+ 196: 196,
204
+ 197: 197,
205
+ 198: 198,
206
+ 199: 199,
207
+ 200: 200,
208
+ 201: 201,
209
+ 202: 202,
210
+ 203: 203,
211
+ 204: 204,
212
+ 205: 205,
213
+ 206: 206,
214
+ 207: 207,
215
+ 208: 208,
216
+ 209: 209,
217
+ 210: 210,
218
+ 211: 211,
219
+ 212: 212,
220
+ 213: 213,
221
+ 214: 214,
222
+ 215: 215,
223
+ 216: 216,
224
+ 217: 217,
225
+ 218: 218,
226
+ 219: 219,
227
+ 220: 220,
228
+ 221: 221,
229
+ 222: 222,
230
+ 223: 223,
231
+ 224: 224,
232
+ 225: 225,
233
+ 226: 226,
234
+ 227: 227,
235
+ 228: 228,
236
+ 229: 229,
237
+ 230: 230,
238
+ 231: 231,
239
+ 232: 232,
240
+ 233: 233,
241
+ 234: 234,
242
+ 235: 235,
243
+ 236: 236,
244
+ 237: 237,
245
+ 238: 238,
246
+ 239: 239,
247
+ 240: 240,
248
+ 241: 241,
249
+ 242: 242,
250
+ 243: 243,
251
+ 244: 244,
252
+ 245: 245,
253
+ 246: 246,
254
+ 247: 247,
255
+ 248: 248,
256
+ 249: 249,
257
+ 250: 250,
258
+ 251: 251,
259
+ 252: 252,
260
+ 253: 253,
261
+ 254: 255,
262
+ 255: 255,
263
+ 256: 0,
264
+ 257: 1,
265
+ 258: 2,
266
+ 259: 3,
267
+ 260: 4,
268
+ 261: 5,
269
+ 262: 6,
270
+ 263: 7,
271
+ 264: 8,
272
+ 265: 9,
273
+ 266: 10,
274
+ 267: 11,
275
+ 268: 12,
276
+ 269: 13,
277
+ 270: 14,
278
+ 271: 15,
279
+ 272: 16,
280
+ 273: 17,
281
+ 274: 18,
282
+ 275: 19,
283
+ 276: 20,
284
+ 277: 21,
285
+ 278: 22,
286
+ 279: 23,
287
+ 280: 24,
288
+ 281: 25,
289
+ 282: 26,
290
+ 283: 27,
291
+ 284: 28,
292
+ 285: 29,
293
+ 286: 30,
294
+ 287: 31,
295
+ 288: 32,
296
+ 289: 127,
297
+ 290: 128,
298
+ 291: 129,
299
+ 292: 130,
300
+ 293: 131,
301
+ 294: 132,
302
+ 295: 133,
303
+ 296: 134,
304
+ 297: 135,
305
+ 298: 136,
306
+ 299: 137,
307
+ 300: 138,
308
+ 301: 139,
309
+ 302: 140,
310
+ 303: 141,
311
+ 304: 142,
312
+ 305: 143,
313
+ 306: 144,
314
+ 307: 145,
315
+ 308: 146,
316
+ 309: 147,
317
+ 310: 148,
318
+ 311: 149,
319
+ 312: 150,
320
+ 313: 151,
321
+ 314: 152,
322
+ 315: 153,
323
+ 316: 154,
324
+ 317: 155,
325
+ 318: 156,
326
+ 319: 157,
327
+ 320: 158,
328
+ 321: 159,
329
+ 322: 160,
330
+ 323: 173,
331
};
lib/core/utils/safe_file_picker.dart
+15
-15
@@ -96,9 +96,9 @@ class SafeFilePicker {
96
final l10n = AppLocalizations.of(context);
97
if (l10n != null) {
98
final msg = getErrorMessage(e, l10n);
99
- ScaffoldMessenger.of(context).showSnackBar(
100
- SnackBar(content: Text(msg)),
101
- );
99
+ ScaffoldMessenger.of(
100
+ context,
101
+ ).showSnackBar(SnackBar(content: Text(msg)));
102
}
103
} else if (onError != null) {
104
onError(e.toString());
@@ -135,9 +135,9 @@ class SafeFilePicker {
135
final l10n = AppLocalizations.of(context);
136
if (l10n != null) {
137
final msg = getErrorMessage(e, l10n);
138
- ScaffoldMessenger.of(context).showSnackBar(
139
- SnackBar(content: Text(msg)),
140
- );
138
+ ScaffoldMessenger.of(
139
+ context,
140
+ ).showSnackBar(SnackBar(content: Text(msg)));
141
}
142
} else if (onError != null) {
143
onError(e.toString());
@@ -166,9 +166,9 @@ class SafeFilePicker {
166
final l10n = AppLocalizations.of(context);
167
if (l10n != null) {
168
final msg = getErrorMessage(e, l10n);
169
- ScaffoldMessenger.of(context).showSnackBar(
170
- SnackBar(content: Text(msg)),
171
- );
169
+ ScaffoldMessenger.of(
170
+ context,
171
+ ).showSnackBar(SnackBar(content: Text(msg)));
172
}
173
} else if (onError != null) {
174
onError(e.toString());
@@ -200,9 +200,9 @@ class SafeFilePicker {
200
final l10n = AppLocalizations.of(context);
201
if (l10n != null) {
202
final msg = getErrorMessage(e, l10n);
203
- ScaffoldMessenger.of(context).showSnackBar(
204
- SnackBar(content: Text(msg)),
205
- );
203
+ ScaffoldMessenger.of(
204
+ context,
205
+ ).showSnackBar(SnackBar(content: Text(msg)));
206
}
207
} else if (onError != null) {
208
onError(e.toString());
@@ -238,9 +238,9 @@ class SafeFilePicker {
238
final l10n = AppLocalizations.of(context);
239
if (l10n != null) {
240
final msg = getErrorMessage(e, l10n);
241
- ScaffoldMessenger.of(context).showSnackBar(
242
- SnackBar(content: Text(msg)),
243
- );
241
+ ScaffoldMessenger.of(
242
+ context,
243
+ ).showSnackBar(SnackBar(content: Text(msg)));
244
}
245
} else if (onError != null) {
246
onError(e.toString());
lib/core/widgets/crash_error_widget.dart
+2
-5
@@ -142,8 +142,7 @@ class CrashErrorWidget extends StatelessWidget {
142
),
143
child: SelectableText(
144
previewLines.isEmpty
145
- ? (l10n?.crash_report_empty_stack ??
146
- '<empty>')
145
+ ? (l10n?.crash_report_empty_stack ?? '<empty>')
146
: previewLines,
147
style: const TextStyle(
148
fontFamily: 'monospace',
@@ -239,9 +238,7 @@ class CrashErrorWidget extends StatelessWidget {
238
if (messenger != null) {
239
messenger.showSnackBar(
240
SnackBar(
242
- content: Text(
243
- l10n?.crash_report_copied ?? 'Copied to clipboard',
244
- ),
241
+ content: Text(l10n?.crash_report_copied ?? 'Copied to clipboard'),
242
),
243
);
244
}
lib/features/chat/data/chat_api_error.dart
+6
-11
@@ -2,12 +2,7 @@ import 'dart:convert';
2
3
/// Structured API error details from LM Studio, OpenAI-compatible, etc.
4
class ChatApiError {
5
- const ChatApiError({
6
- required this.message,
7
- this.type,
8
- this.code,
9
- this.param,
10
- });
5
+ const ChatApiError({required this.message, this.type, this.code, this.param});
6
7
final String message;
8
final String? type;
@@ -22,11 +17,11 @@ class ChatApiError {
17
String encode() => jsonEncode(toMap());
18
19
Map<String, dynamic> toMap() => {
25
- 'message': message,
26
- if (type != null && type!.isNotEmpty) 'type': type,
27
- if (code != null && code!.isNotEmpty) 'code': code,
28
- if (param != null && param!.isNotEmpty) 'param': param,
29
- };
20
+ 'message': message,
21
+ if (type != null && type!.isNotEmpty) 'type': type,
22
+ if (code != null && code!.isNotEmpty) 'code': code,
23
+ if (param != null && param!.isNotEmpty) 'param': param,
24
+ };
25
26
ChatApiError copyWithCodeIfMissing(String? newCode) {
27
if (code != null && code!.isNotEmpty) return this;
lib/features/chat/data/chat_error_formatter.dart
+4
-2
@@ -67,7 +67,9 @@ class ChatErrorFormatter {
67
68
if (errorType != null) {
69
final fromStatus = _genericFromStatus(statusCode);
70
- return fromStatus.copyWithTypeIfMissing(_dioTypeLabel(errorType)).encode();
70
+ return fromStatus
71
+ .copyWithTypeIfMissing(_dioTypeLabel(errorType))
72
+ .encode();
73
}
74
75
return ChatApiError(message: error.toString()).encode();
@@ -283,4 +285,4 @@ class ChatErrorFormatter {
285
return null;
286
}
287
}
286
-}
\ No newline at end of file
288
+}
lib/features/chat/data/chat_service.dart
+111
-114
@@ -303,8 +303,7 @@ class LMStudioChatService implements ChatService {
303
}
304
305
// Prefer the SSE `event:` line, fall back to the JSON `type` field.
306
- final eventType =
307
- currentEventName ?? json['type'] as String? ?? '';
306
+ final eventType = currentEventName ?? json['type'] as String? ?? '';
307
308
switch (eventType) {
309
case 'chat.start':
@@ -405,8 +404,8 @@ class LMStudioChatService implements ChatService {
404
tool: m['tool'] as String? ?? '',
405
arguments:
406
(m['arguments'] as Map?)
408
- ?.cast<String, dynamic>() ??
409
- {},
407
+ ?.cast<String, dynamic>() ??
408
+ {},
409
output: m['output'] as String?,
410
providerInfo: _providerInfoFromMap(
411
(m['provider_info'] as Map?)
@@ -593,13 +592,13 @@ class LMStudioChatService implements ChatService {
592
return ChatStats(
593
inputTokens: (stats['input_tokens'] as num?)?.toInt() ?? 0,
594
totalOutputTokens: (stats['total_output_tokens'] as num?)?.toInt() ?? 0,
596
- reasoningOutputTokens:
597
- (stats['reasoning_output_tokens'] as num?)?.toInt(),
595
+ reasoningOutputTokens: (stats['reasoning_output_tokens'] as num?)
596
+ ?.toInt(),
597
tokensPerSecond: (stats['tokens_per_second'] as num?)?.toDouble(),
599
- timeToFirstTokenSeconds:
600
- (stats['time_to_first_token_seconds'] as num?)?.toDouble(),
601
- modelLoadTimeSeconds:
602
- (stats['model_load_time_seconds'] as num?)?.toDouble(),
598
+ timeToFirstTokenSeconds: (stats['time_to_first_token_seconds'] as num?)
599
+ ?.toDouble(),
600
+ modelLoadTimeSeconds: (stats['model_load_time_seconds'] as num?)
601
+ ?.toDouble(),
602
);
603
}
604
@@ -781,7 +780,8 @@ class OpenAICompatibleChatService implements ChatService {
780
781
final content = (delta['content'] ?? delta['text']) as String?;
782
final reasoning =
784
- (delta['reasoning'] ?? delta['reasoning_content']) as String?;
783
+ (delta['reasoning'] ?? delta['reasoning_content'])
784
+ as String?;
785
final refusal = delta['refusal'] as String?;
786
787
// Check if content is empty/null (processing but not outputting)
@@ -833,10 +833,7 @@ class OpenAICompatibleChatService implements ChatService {
833
for (final call in toolAdapter.takeCompletedCalls()) {
834
yield ChatResponse(
835
type: ChatResponseType.toolCall,
836
- toolCall: ToolCallData(
837
- tool: call.name,
838
- arguments: call.arguments,
839
- ),
836
+ toolCall: ToolCallData(tool: call.name, arguments: call.arguments),
837
);
838
}
839
} catch (e) {
@@ -978,7 +975,8 @@ class OllamaChatService implements ChatService {
975
Log.error('Ollama HTTP $statusCode: ${parsedError ?? rawError}');
976
yield ChatResponse(
977
type: ChatResponseType.error,
981
- content: parsedError ??
978
+ content:
979
+ parsedError ??
980
(rawError.trim().isNotEmpty
981
? rawError.trim()
982
: 'Ollama request failed (HTTP $statusCode).'),
@@ -1240,110 +1238,111 @@ class OpenRouterChatService implements ChatService {
1238
return;
1239
}
1240
1243
- buffer += chunk;
1244
- final lines = buffer.split('\n');
1245
- buffer = lines.removeLast();
1246
-
1247
- for (final line in lines) {
1248
- logCounter++;
1249
- if (logCounter % 10 == 0) {
1250
- Log.debug('OpenRouter SSE line #$logCounter: $line');
1251
- }
1241
+ buffer += chunk;
1242
+ final lines = buffer.split('\n');
1243
+ buffer = lines.removeLast();
1244
1253
- if (line.startsWith('data: ')) {
1254
- final data = line.substring(6);
1255
- if (data == '[DONE]') {
1256
- _timeoutTimer?.cancel();
1257
- Log.debug('OpenRouter: Received [DONE]');
1258
- for (final call in toolAdapter.takeCompletedCalls()) {
1259
- yield ChatResponse(
1260
- type: ChatResponseType.toolCall,
1261
- toolCall: ToolCallData(
1262
- tool: call.name,
1263
- arguments: call.arguments,
1264
- ),
1265
- );
1266
- }
1267
- yield const ChatResponse(type: ChatResponseType.done);
1268
- return;
1245
+ for (final line in lines) {
1246
+ logCounter++;
1247
+ if (logCounter % 10 == 0) {
1248
+ Log.debug('OpenRouter SSE line #$logCounter: $line');
1249
}
1270
- try {
1271
- final json = jsonDecode(data) as Map<String, dynamic>;
1272
- toolAdapter.consumeDynamicChunk(json);
1250
1274
- // Check for errors in the response
1275
- if (json['error'] != null) {
1251
+ if (line.startsWith('data: ')) {
1252
+ final data = line.substring(6);
1253
+ if (data == '[DONE]') {
1254
_timeoutTimer?.cancel();
1277
- final errorContent = _formatApiErrorContent(json['error']);
1278
- Log.error('OpenRouter mid-stream error: $errorContent');
1279
- yield ChatResponse(
1280
- type: ChatResponseType.error,
1281
- content: errorContent,
1282
- );
1255
+ Log.debug('OpenRouter: Received [DONE]');
1256
+ for (final call in toolAdapter.takeCompletedCalls()) {
1257
+ yield ChatResponse(
1258
+ type: ChatResponseType.toolCall,
1259
+ toolCall: ToolCallData(
1260
+ tool: call.name,
1261
+ arguments: call.arguments,
1262
+ ),
1263
+ );
1264
+ }
1265
+ yield const ChatResponse(type: ChatResponseType.done);
1266
return;
1267
}
1268
+ try {
1269
+ final json = jsonDecode(data) as Map<String, dynamic>;
1270
+ toolAdapter.consumeDynamicChunk(json);
1271
1286
- final choices = json['choices'] as List<dynamic>?;
1287
- if (choices == null || choices.isEmpty) {
1288
- continue;
1289
- }
1290
-
1291
- final firstChoice = choices[0] as Map<String, dynamic>?;
1292
- if (firstChoice == null) continue;
1293
-
1294
- final delta = firstChoice['delta'];
1295
- if (delta == null || delta is! Map<String, dynamic>) continue;
1296
-
1297
- final content = (delta['content'] ?? delta['text']) as String?;
1298
- final reasoning =
1299
- (delta['reasoning'] ?? delta['reasoning_content']) as String?;
1300
- final refusal = delta['refusal'] as String?;
1301
-
1302
- // Check if content is empty/null (processing but not outputting)
1303
- final hasContent = content != null && content.isNotEmpty;
1304
- final hasReasoning = reasoning != null && reasoning.isNotEmpty;
1305
- final hasRefusal = refusal != null && refusal.isNotEmpty;
1306
-
1307
- if (!hasContent && !hasReasoning && !hasRefusal) {
1308
- emptyDeltaCount++;
1309
- if (emptyDeltaCount % 10 == 0) {
1310
- yield const ChatResponse(type: ChatResponseType.processing);
1311
- }
1312
- } else {
1313
- emptyDeltaCount = 0;
1314
- lastContentReceived = DateTime.now();
1315
-
1316
- if (hasRefusal) {
1272
+ // Check for errors in the response
1273
+ if (json['error'] != null) {
1274
+ _timeoutTimer?.cancel();
1275
+ final errorContent = _formatApiErrorContent(json['error']);
1276
+ Log.error('OpenRouter mid-stream error: $errorContent');
1277
yield ChatResponse(
1278
type: ChatResponseType.error,
1319
- content: 'Refusal: $refusal',
1279
+ content: errorContent,
1280
);
1281
return;
1282
}
1323
- if (hasContent) {
1324
- yield ChatResponse(
1325
- type: ChatResponseType.message,
1326
- content: content,
1327
- );
1283
+
1284
+ final choices = json['choices'] as List<dynamic>?;
1285
+ if (choices == null || choices.isEmpty) {
1286
+ continue;
1287
}
1329
- if (hasReasoning) {
1330
- yield ChatResponse(
1331
- type: ChatResponseType.reasoning,
1332
- reasoningContent: reasoning,
1333
- );
1288
+
1289
+ final firstChoice = choices[0] as Map<String, dynamic>?;
1290
+ if (firstChoice == null) continue;
1291
+
1292
+ final delta = firstChoice['delta'];
1293
+ if (delta == null || delta is! Map<String, dynamic>) continue;
1294
+
1295
+ final content = (delta['content'] ?? delta['text']) as String?;
1296
+ final reasoning =
1297
+ (delta['reasoning'] ?? delta['reasoning_content'])
1298
+ as String?;
1299
+ final refusal = delta['refusal'] as String?;
1300
+
1301
+ // Check if content is empty/null (processing but not outputting)
1302
+ final hasContent = content != null && content.isNotEmpty;
1303
+ final hasReasoning = reasoning != null && reasoning.isNotEmpty;
1304
+ final hasRefusal = refusal != null && refusal.isNotEmpty;
1305
+
1306
+ if (!hasContent && !hasReasoning && !hasRefusal) {
1307
+ emptyDeltaCount++;
1308
+ if (emptyDeltaCount % 10 == 0) {
1309
+ yield const ChatResponse(type: ChatResponseType.processing);
1310
+ }
1311
+ } else {
1312
+ emptyDeltaCount = 0;
1313
+ lastContentReceived = DateTime.now();
1314
+
1315
+ if (hasRefusal) {
1316
+ yield ChatResponse(
1317
+ type: ChatResponseType.error,
1318
+ content: 'Refusal: $refusal',
1319
+ );
1320
+ return;
1321
+ }
1322
+ if (hasContent) {
1323
+ yield ChatResponse(
1324
+ type: ChatResponseType.message,
1325
+ content: content,
1326
+ );
1327
+ }
1328
+ if (hasReasoning) {
1329
+ yield ChatResponse(
1330
+ type: ChatResponseType.reasoning,
1331
+ reasoningContent: reasoning,
1332
+ );
1333
+ }
1334
}
1335
+ } catch (e) {
1336
+ Log.error('OpenRouter parsing error: $e');
1337
+ }
1338
+ } else if (line.startsWith(': ')) {
1339
+ // SSE comment (keep-alive)
1340
+ if (logCounter % 50 == 0) {
1341
+ Log.debug('OpenRouter SSE comment: $line');
1342
}
1336
- } catch (e) {
1337
- Log.error('OpenRouter parsing error: $e');
1338
- }
1339
- } else if (line.startsWith(': ')) {
1340
- // SSE comment (keep-alive)
1341
- if (logCounter % 50 == 0) {
1342
- Log.debug('OpenRouter SSE comment: $line');
1343
}
1344
}
1345
}
1346
- }
1346
} finally {
1347
_timeoutTimer?.cancel();
1348
}
@@ -1353,10 +1352,7 @@ class OpenRouterChatService implements ChatService {
1352
for (final call in toolAdapter.takeCompletedCalls()) {
1353
yield ChatResponse(
1354
type: ChatResponseType.toolCall,
1356
- toolCall: ToolCallData(
1357
- tool: call.name,
1358
- arguments: call.arguments,
1359
- ),
1355
+ toolCall: ToolCallData(tool: call.name, arguments: call.arguments),
1356
);
1357
}
1358
} catch (e) {
@@ -1619,11 +1615,12 @@ bool _looksLikePng(Uint8List bytes) {
1615
/// from streaming chunks. LM Studio and on-device servers collect tool calls
1616
/// directly from [ChatResponseType.toolCall] events emitted during streaming
1617
/// and feed them to the loop via [ToolExecutionLoop.run]'s [preParsedCalls].
1622
-ToolTransportAdapter createAdapterForServerType(ServerType type) => switch (type) {
1623
- ServerType.openAICompatible => OpenAiToolAdapter(),
1624
- ServerType.openRouter => OpenRouterToolAdapter(),
1625
- ServerType.lmStudio => OpenAiToolAdapter(),
1626
- ServerType.onDevice => OpenAiToolAdapter(),
1627
- ServerType.ollama => OllamaToolAdapter(),
1628
- ServerType.ollamaCloud => OllamaToolAdapter(),
1629
-};
1618
+ToolTransportAdapter createAdapterForServerType(ServerType type) =>
1619
+ switch (type) {
1620
+ ServerType.openAICompatible => OpenAiToolAdapter(),
1621
+ ServerType.openRouter => OpenRouterToolAdapter(),
1622
+ ServerType.lmStudio => OpenAiToolAdapter(),
1623
+ ServerType.onDevice => OpenAiToolAdapter(),
1624
+ ServerType.ollama => OllamaToolAdapter(),
1625
+ ServerType.ollamaCloud => OllamaToolAdapter(),
1626
+ };
lib/features/chat/data/mcp_client.dart
+50
-17
@@ -26,8 +26,8 @@ class McpClient {
26
this.headers,
27
String version = '1.0.0',
28
Dio? dio,
29
- }) : _version = version,
30
- _dio = dio ?? Dio();
29
+ }) : _version = version,
30
+ _dio = dio ?? Dio();
31
32
bool get isInitialized => _initialized;
33
McpCapabilities? get capabilities => _capabilities;
@@ -47,7 +47,8 @@ class McpClient {
47
);
48
49
final contentType = response.headers.value('content-type') ?? '';
50
- if (!contentType.contains('text/event-stream') && !serverUrl.contains('sse')) {
50
+ if (!contentType.contains('text/event-stream') &&
51
+ !serverUrl.contains('sse')) {
52
// Not an SSE stream endpoint, fallback to direct HTTP POST
53
return;
54
}
@@ -55,7 +56,9 @@ class McpClient {
56
_useSse = true;
57
final handshakeCompleter = Completer<void>();
58
58
- final stream = response.data!.stream.cast<List<int>>().transform(utf8.decoder);
59
+ final stream = response.data!.stream.cast<List<int>>().transform(
60
+ utf8.decoder,
61
+ );
62
String buffer = '';
63
String currentEvent = 'message';
64
String currentData = '';
@@ -70,7 +73,11 @@ class McpClient {
73
final trimmed = line.trim();
74
if (trimmed.isEmpty) {
75
if (currentData.isNotEmpty) {
73
- _handleSseMessage(currentEvent, currentData, handshakeCompleter);
76
+ _handleSseMessage(
77
+ currentEvent,
78
+ currentData,
79
+ handshakeCompleter,
80
+ );
81
currentEvent = 'message';
82
currentData = '';
83
}
@@ -101,11 +108,17 @@ class McpClient {
108
if (currentData.isNotEmpty) {
109
_handleSseMessage(currentEvent, currentData, handshakeCompleter);
110
} else if (buffer.trim().isNotEmpty) {
104
- _handleSseMessage(currentEvent, buffer.trim(), handshakeCompleter);
111
+ _handleSseMessage(
112
+ currentEvent,
113
+ buffer.trim(),
114
+ handshakeCompleter,
115
+ );
116
}
117
}
118
if (!handshakeCompleter.isCompleted) {
108
- handshakeCompleter.completeError(McpException('SSE stream closed before handshake completed'));
119
+ handshakeCompleter.completeError(
120
+ McpException('SSE stream closed before handshake completed'),
121
+ );
122
}
123
_handleSseDisconnect(null);
124
},
@@ -115,7 +128,9 @@ class McpClient {
128
await handshakeCompleter.future.timeout(
129
const Duration(seconds: 10),
130
onTimeout: () {
118
- throw McpException('Handshake timeout waiting for SSE endpoint event');
131
+ throw McpException(
132
+ 'Handshake timeout waiting for SSE endpoint event',
133
+ );
134
},
135
);
136
} catch (e) {
@@ -125,7 +140,11 @@ class McpClient {
140
}
141
}
142
128
- void _handleSseMessage(String event, String data, Completer<void> initCompleter) {
143
+ void _handleSseMessage(
144
+ String event,
145
+ String data,
146
+ Completer<void> initCompleter,
147
+ ) {
148
if (event == 'endpoint') {
149
try {
150
final baseUri = Uri.parse(serverUrl);
@@ -158,7 +177,9 @@ class McpClient {
177
}
178
179
void _handleSseDisconnect(dynamic error) {
161
- final ex = McpException(error != null ? 'SSE disconnected: $error' : 'SSE connection closed');
180
+ final ex = McpException(
181
+ error != null ? 'SSE disconnected: $error' : 'SSE connection closed',
182
+ );
183
for (final completer in _pendingRequests.values) {
184
if (!completer.isCompleted) {
185
completer.completeError(ex);
@@ -174,7 +195,10 @@ class McpClient {
195
_postUrl = null;
196
}
197
177
- Future<Map<String, dynamic>> _sendJsonRpc(String method, Map<String, dynamic> params) async {
198
+ Future<Map<String, dynamic>> _sendJsonRpc(
199
+ String method,
200
+ Map<String, dynamic> params,
201
+ ) async {
202
final id = _requestIdCounter++;
203
final payload = {
204
'jsonrpc': '2.0',
@@ -185,7 +209,9 @@ class McpClient {
209
210
if (_useSse) {
211
if (_postUrl == null) {
188
- throw McpException('SSE transport initialized but POST endpoint is missing');
212
+ throw McpException(
213
+ 'SSE transport initialized but POST endpoint is missing',
214
+ );
215
}
216
217
final completer = Completer<Map<String, dynamic>>();
@@ -208,13 +234,17 @@ class McpClient {
234
const Duration(seconds: 30),
235
onTimeout: () {
236
_pendingRequests.remove(id.toString());
211
- throw McpException('Timeout waiting for JSON-RPC response over SSE transport');
237
+ throw McpException(
238
+ 'Timeout waiting for JSON-RPC response over SSE transport',
239
+ );
240
},
241
);
242
243
if (responseJson.containsKey('error')) {
244
final error = responseJson['error'];
217
- final message = error is Map ? (error['message'] ?? 'Unknown error') : 'Unknown error';
245
+ final message = error is Map
246
+ ? (error['message'] ?? 'Unknown error')
247
+ : 'Unknown error';
248
throw McpException('Server returned error: $message');
249
}
250
@@ -236,7 +266,9 @@ class McpClient {
266
267
if (responseData.containsKey('error')) {
268
final error = responseData['error'];
239
- final message = error is Map ? (error['message'] ?? 'Unknown error') : 'Unknown error';
269
+ final message = error is Map
270
+ ? (error['message'] ?? 'Unknown error')
271
+ : 'Unknown error';
272
throw McpException('Server returned error: $message');
273
}
274
@@ -266,12 +298,13 @@ class McpClient {
298
}
299
300
Future<McpCapabilities> _doInitialize() async {
269
-
301
try {
302
try {
303
await _connectSse();
304
} catch (e) {
274
- Log.debug('Failed to connect via SSE: $e. Falling back to direct HTTP POST.');
305
+ Log.debug(
306
+ 'Failed to connect via SSE: $e. Falling back to direct HTTP POST.',
307
+ );
308
_useSse = false;
309
}
310
lib/features/chat/data/models/chat_parameters.dart
+1
@@ -10,6 +10,7 @@ class ChatParameters {
10
final int? topK;
11
final double? minP;
12
final double? repeatPenalty;
13
+
14
/// Null when the active model doesn't support reasoning (no reasoning
15
/// control fields should be sent); otherwise reflects the Think toggle.
16
final bool? reasoningEnabled;
lib/features/chat/data/tools/adapters/lm_studio_tool_adapter.dart
+22
-16
@@ -22,8 +22,8 @@ class LmStudioToolAdapter {
22
case 'tool_call.start':
23
_currentTool = data['tool'] as String?;
24
_currentArgs = {};
25
- _currentProviderInfo =
26
- (data['provider_info'] as Map?)?.cast<String, dynamic>();
25
+ _currentProviderInfo = (data['provider_info'] as Map?)
26
+ ?.cast<String, dynamic>();
27
break;
28
case 'tool_call.arguments':
29
final args = data['arguments'] as Map<String, dynamic>?;
@@ -39,15 +39,18 @@ class LmStudioToolAdapter {
39
final tool = data['tool'] as String? ?? _currentTool;
40
final args = data['arguments'] as Map<String, dynamic>? ?? _currentArgs;
41
final output = data['output'] as String? ?? '';
42
- final pi = (data['provider_info'] as Map?)?.cast<String, dynamic>() ??
42
+ final pi =
43
+ (data['provider_info'] as Map?)?.cast<String, dynamic>() ??
44
_currentProviderInfo;
45
if (tool != null) {
45
- _completedCalls.add(ServerExecutedToolCall(
46
- name: tool,
47
- arguments: args,
48
- output: output,
49
- providerInfo: pi,
50
- ));
46
+ _completedCalls.add(
47
+ ServerExecutedToolCall(
48
+ name: tool,
49
+ arguments: args,
50
+ output: output,
51
+ providerInfo: pi,
52
+ ),
53
+ );
54
}
55
_currentTool = null;
56
_currentArgs = {};
@@ -57,15 +60,18 @@ class LmStudioToolAdapter {
60
final tool = data['tool'] as String? ?? _currentTool;
61
final args = data['arguments'] as Map<String, dynamic>? ?? _currentArgs;
62
final reason = data['reason'] as String? ?? 'Tool execution failed';
60
- final pi = (data['provider_info'] as Map?)?.cast<String, dynamic>() ??
63
+ final pi =
64
+ (data['provider_info'] as Map?)?.cast<String, dynamic>() ??
65
_currentProviderInfo;
66
if (tool != null) {
63
- _completedCalls.add(ServerExecutedToolCall(
64
- name: tool,
65
- arguments: args,
66
- output: reason,
67
- providerInfo: pi,
68
- ));
67
+ _completedCalls.add(
68
+ ServerExecutedToolCall(
69
+ name: tool,
70
+ arguments: args,
71
+ output: reason,
72
+ providerInfo: pi,
73
+ ),
74
+ );
75
}
76
_currentTool = null;
77
_currentArgs = {};
lib/features/chat/data/tools/adapters/ollama_tool_adapter.dart
+21
-19
@@ -9,14 +9,18 @@ class OllamaToolAdapter implements ToolTransportAdapter {
9
@override
10
Map<String, dynamic> buildToolDefinitionPayload(List<ToolDefinition> tools) {
11
return {
12
- 'tools': tools.map((t) => {
13
- 'type': 'function',
14
- 'function': {
15
- 'name': t.name,
16
- 'description': t.description,
17
- 'parameters': t.inputSchema,
18
- },
19
- }).toList(),
12
+ 'tools': tools
13
+ .map(
14
+ (t) => {
15
+ 'type': 'function',
16
+ 'function': {
17
+ 'name': t.name,
18
+ 'description': t.description,
19
+ 'parameters': t.inputSchema,
20
+ },
21
+ },
22
+ )
23
+ .toList(),
24
};
25
}
26
@@ -29,13 +33,15 @@ class OllamaToolAdapter implements ToolTransportAdapter {
33
for (final tc in toolCalls) {
34
final func = tc['function'] as Map<String, dynamic>?;
35
if (func == null) continue;
32
- _completedCalls.add(ParsedToolCall(
33
- id: tc['id'] ?? '',
34
- name: func['name'] as String? ?? '',
35
- arguments: Map<String, dynamic>.from(
36
- jsonDecode(func['arguments'] as String? ?? '{}') as Map,
36
+ _completedCalls.add(
37
+ ParsedToolCall(
38
+ id: tc['id'] ?? '',
39
+ name: func['name'] as String? ?? '',
40
+ arguments: Map<String, dynamic>.from(
41
+ jsonDecode(func['arguments'] as String? ?? '{}') as Map,
42
+ ),
43
),
38
- ));
44
+ );
45
}
46
}
47
@@ -51,10 +57,6 @@ class OllamaToolAdapter implements ToolTransportAdapter {
57
required ParsedToolCall call,
58
required String output,
59
}) {
54
- return {
55
- 'role': 'tool',
56
- 'tool_name': call.name,
57
- 'content': output,
58
- };
60
+ return {'role': 'tool', 'tool_name': call.name, 'content': output};
61
}
62
}
lib/features/chat/data/tools/adapters/on_device_tool_adapter.dart
+7
-5
@@ -4,11 +4,13 @@ class OnDeviceToolAdapter {
4
final List<ParsedToolCall> _completedCalls = [];
5
6
void consumeFunctionCall(String name, Map<String, dynamic> args) {
7
- _completedCalls.add(ParsedToolCall(
8
- id: 'ondevice_${_completedCalls.length}',
9
- name: name,
10
- arguments: Map<String, dynamic>.from(args),
11
- ));
7
+ _completedCalls.add(
8
+ ParsedToolCall(
9
+ id: 'ondevice_${_completedCalls.length}',
10
+ name: name,
11
+ arguments: Map<String, dynamic>.from(args),
12
+ ),
13
+ );
14
}
15
16
List<ParsedToolCall> takeCompletedCalls() {
lib/features/chat/data/tools/adapters/openai_tool_adapter.dart
+13
-13
@@ -15,14 +15,18 @@ class OpenAiToolAdapter implements ToolTransportAdapter {
15
@override
16
Map<String, dynamic> buildToolDefinitionPayload(List<ToolDefinition> tools) {
17
return {
18
- 'tools': tools.map((t) => {
19
- 'type': 'function',
20
- 'function': {
21
- 'name': t.name,
22
- 'description': t.description,
23
- 'parameters': t.inputSchema,
24
- },
25
- }).toList(),
18
+ 'tools': tools
19
+ .map(
20
+ (t) => {
21
+ 'type': 'function',
22
+ 'function': {
23
+ 'name': t.name,
24
+ 'description': t.description,
25
+ 'parameters': t.inputSchema,
26
+ },
27
+ },
28
+ )
29
+ .toList(),
30
};
31
}
32
@@ -76,10 +80,6 @@ class OpenAiToolAdapter implements ToolTransportAdapter {
80
required ParsedToolCall call,
81
required String output,
82
}) {
79
- return {
80
- 'role': 'tool',
81
- 'tool_call_id': call.id,
82
- 'content': output,
83
- };
83
+ return {'role': 'tool', 'tool_call_id': call.id, 'content': output};
84
}
85
}
lib/features/chat/data/tools/builtin_tool_provider.dart
+6
-12
@@ -55,13 +55,13 @@ class BuiltInToolProvider implements ToolProvider {
55
'type': 'string',
56
'description':
57
'Start date in ISO 8601 format (e.g. 2025-01-15T09:00:00). '
58
- 'Defaults to today.',
58
+ 'Defaults to today.',
59
},
60
'end_date': {
61
'type': 'string',
62
'description':
63
'End date in ISO 8601 format (e.g. 2025-01-22T23:59:59). '
64
- 'Defaults to 7 days from start.',
64
+ 'Defaults to 7 days from start.',
65
},
66
},
67
},
@@ -75,10 +75,7 @@ class BuiltInToolProvider implements ToolProvider {
75
inputSchema: {
76
'type': 'object',
77
'properties': {
78
- 'title': {
79
- 'type': 'string',
80
- 'description': 'Event title / name.',
81
- },
78
+ 'title': {'type': 'string', 'description': 'Event title / name.'},
79
'start_date': {
80
'type': 'string',
81
'description':
@@ -101,8 +98,8 @@ class BuiltInToolProvider implements ToolProvider {
98
'type': 'string',
99
'description':
100
'Optional calendar ID to create the event in. '
104
- 'Use calendar.list_calendars to see available calendars. '
105
- 'If omitted, the device default calendar is used.',
101
+ 'Use calendar.list_calendars to see available calendars. '
102
+ 'If omitted, the device default calendar is used.',
103
},
104
},
105
'required': ['title', 'start_date', 'end_date'],
@@ -115,10 +112,7 @@ class BuiltInToolProvider implements ToolProvider {
112
'List all calendars available on the user\'s device. '
113
'Returns calendar ID, name, account, and read-only status. '
114
'Use this to find the right calendar_id for creating events.',
118
- inputSchema: {
119
- 'type': 'object',
120
- 'properties': {},
121
- },
115
+ inputSchema: {'type': 'object', 'properties': {}},
116
providerType: ToolProviderType.builtIn,
117
),
118
]);
lib/features/chat/data/tools/calendar_service.dart
+22
-21
@@ -34,13 +34,15 @@ class CalendarService {
34
Future<List<Map<String, dynamic>>> listCalendars() async {
35
final calendars = await _plugin.listCalendars();
36
return calendars
37
- .map((c) => {
38
- 'id': c.id,
39
- 'name': c.name,
40
- 'is_read_only': c.readOnly,
41
- 'is_primary': c.isPrimary,
42
- 'account_name': c.accountName,
43
- })
37
+ .map(
38
+ (c) => {
39
+ 'id': c.id,
40
+ 'name': c.name,
41
+ 'is_read_only': c.readOnly,
42
+ 'is_primary': c.isPrimary,
43
+ 'account_name': c.accountName,
44
+ },
45
+ )
46
.toList();
47
}
48
@@ -58,16 +60,18 @@ class CalendarService {
60
61
final events = await _plugin.listEvents(from, to);
62
return events
61
- .map((e) => {
62
- 'id': e.eventId,
63
- 'title': e.title,
64
- 'start': e.startDate.toIso8601String(),
65
- 'end': e.endDate.toIso8601String(),
66
- 'all_day': e.isAllDay,
67
- 'location': e.location,
68
- 'description': e.description,
69
- 'calendar_id': e.calendarId,
70
- })
63
+ .map(
64
+ (e) => {
65
+ 'id': e.eventId,
66
+ 'title': e.title,
67
+ 'start': e.startDate.toIso8601String(),
68
+ 'end': e.endDate.toIso8601String(),
69
+ 'all_day': e.isAllDay,
70
+ 'location': e.location,
71
+ 'description': e.description,
72
+ 'calendar_id': e.calendarId,
73
+ },
74
+ )
75
.toList();
76
}
77
@@ -100,10 +104,7 @@ class CalendarService {
104
'message': 'Event "$title" created successfully.',
105
};
106
} on DeviceCalendarException catch (e) {
103
- return {
104
- 'success': false,
105
- 'error': e.toString(),
106
- };
107
+ return {'success': false, 'error': e.toString()};
108
}
109
}
110
lib/features/chat/data/tools/mcp_tool_provider.dart
+9
-7
@@ -14,13 +14,15 @@ class McpToolProvider implements ToolProvider {
14
for (final label in serverManager.serverLabels) {
15
final serverTools = serverManager.getTools(label);
16
for (final tool in serverTools) {
17
- tools.add(ToolDefinition(
18
- name: tool.name,
19
- description: tool.description ?? '',
20
- inputSchema: tool.inputSchema,
21
- providerType: ToolProviderType.mcp,
22
- providerRef: label,
23
- ));
17
+ tools.add(
18
+ ToolDefinition(
19
+ name: tool.name,
20
+ description: tool.description ?? '',
21
+ inputSchema: tool.inputSchema,
22
+ providerType: ToolProviderType.mcp,
23
+ providerRef: label,
24
+ ),
25
+ );
26
}
27
}
28
return tools;
lib/features/chat/data/tools/tool_definition.dart
+2
-6
@@ -21,11 +21,7 @@ class ToolExecutionResult {
21
final String output;
22
final String? error;
23
24
- const ToolExecutionResult.success(this.output)
25
- : success = true,
26
- error = null;
24
+ const ToolExecutionResult.success(this.output) : success = true, error = null;
25
28
- const ToolExecutionResult.failure(this.error)
29
- : success = false,
30
- output = '';
26
+ const ToolExecutionResult.failure(this.error) : success = false, output = '';
27
}
lib/features/chat/data/tools/tool_event.dart
+17
-11
@@ -1,6 +1,13 @@
1
import 'tool_definition.dart';
2
3
-enum ToolEventStatus { requested, approved, rejected, running, completed, failed }
3
+enum ToolEventStatus {
4
+ requested,
5
+ approved,
6
+ rejected,
7
+ running,
8
+ completed,
9
+ failed,
10
+}
11
12
class ToolEvent {
13
final String eventId;
@@ -33,16 +40,15 @@ class ToolEvent {
40
required ToolProviderType providerType,
41
String? providerRef,
42
Map<String, dynamic>? arguments,
36
- }) =>
37
- ToolEvent(
38
- eventId: eventId,
39
- timestamp: DateTime.now(),
40
- status: ToolEventStatus.requested,
41
- toolName: toolName,
42
- providerType: providerType,
43
- providerRef: providerRef,
44
- arguments: arguments,
45
- );
43
+ }) => ToolEvent(
44
+ eventId: eventId,
45
+ timestamp: DateTime.now(),
46
+ status: ToolEventStatus.requested,
47
+ toolName: toolName,
48
+ providerType: providerType,
49
+ providerRef: providerRef,
50
+ arguments: arguments,
51
+ );
52
53
Map<String, dynamic> toMap() {
54
return {
lib/features/chat/data/tools/tool_execution_loop.dart
+93
-73
@@ -73,25 +73,45 @@ class ToolExecutionLoop {
73
toolCallCount++;
74
final baseId = '${_sessionId}_${iteration}_${call.id}';
75
final toolDefinition = toolDefinitions[call.name];
76
- final providerType = toolDefinition?.providerType ?? ToolProviderType.builtIn;
76
+ final providerType =
77
+ toolDefinition?.providerType ?? ToolProviderType.builtIn;
78
final providerRef = toolDefinition?.providerRef;
79
80
if (onRequestApproval != null) {
80
- _events.add(ToolEvent(
81
- eventId: '$baseId.requested',
82
- timestamp: DateTime.now(),
83
- status: ToolEventStatus.requested,
84
- toolName: call.name,
85
- providerType: providerType,
86
- providerRef: providerRef,
87
- arguments: call.arguments,
88
- ));
81
+ _events.add(
82
+ ToolEvent(
83
+ eventId: '$baseId.requested',
84
+ timestamp: DateTime.now(),
85
+ status: ToolEventStatus.requested,
86
+ toolName: call.name,
87
+ providerType: providerType,
88
+ providerRef: providerRef,
89
+ arguments: call.arguments,
90
+ ),
91
+ );
92
93
try {
91
- final approved = await onRequestApproval!(call)
92
- .timeout(approvalTimeout);
94
+ final approved = await onRequestApproval!(
95
+ call,
96
+ ).timeout(approvalTimeout);
97
if (!approved) {
94
- _events.add(ToolEvent(
98
+ _events.add(
99
+ ToolEvent(
100
+ eventId: '$baseId.rejected',
101
+ timestamp: DateTime.now(),
102
+ status: ToolEventStatus.rejected,
103
+ toolName: call.name,
104
+ providerType: providerType,
105
+ providerRef: providerRef,
106
+ arguments: call.arguments,
107
+ error: 'Rejected by user',
108
+ ),
109
+ );
110
+ continue;
111
+ }
112
+ } on TimeoutException {
113
+ _events.add(
114
+ ToolEvent(
115
eventId: '$baseId.rejected',
116
timestamp: DateTime.now(),
117
status: ToolEventStatus.rejected,
@@ -99,56 +119,50 @@ class ToolExecutionLoop {
119
providerType: providerType,
120
providerRef: providerRef,
121
arguments: call.arguments,
102
- error: 'Rejected by user',
103
- ));
104
- continue;
105
- }
106
- } on TimeoutException {
107
- _events.add(ToolEvent(
108
- eventId: '$baseId.rejected',
109
- timestamp: DateTime.now(),
110
- status: ToolEventStatus.rejected,
111
- toolName: call.name,
112
- providerType: providerType,
113
- providerRef: providerRef,
114
- arguments: call.arguments,
115
- error: 'Approval timed out',
116
- ));
122
+ error: 'Approval timed out',
123
+ ),
124
+ );
125
continue;
126
} catch (_) {
119
- _events.add(ToolEvent(
120
- eventId: '$baseId.failed',
127
+ _events.add(
128
+ ToolEvent(
129
+ eventId: '$baseId.failed',
130
+ timestamp: DateTime.now(),
131
+ status: ToolEventStatus.failed,
132
+ toolName: call.name,
133
+ providerType: providerType,
134
+ providerRef: providerRef,
135
+ arguments: call.arguments,
136
+ error: 'Approval callback error',
137
+ ),
138
+ );
139
+ continue;
140
+ }
141
+
142
+ _events.add(
143
+ ToolEvent(
144
+ eventId: '$baseId.approved',
145
timestamp: DateTime.now(),
122
- status: ToolEventStatus.failed,
146
+ status: ToolEventStatus.approved,
147
toolName: call.name,
148
providerType: providerType,
149
providerRef: providerRef,
150
arguments: call.arguments,
127
- error: 'Approval callback error',
128
- ));
129
- continue;
130
- }
151
+ ),
152
+ );
153
+ }
154
132
- _events.add(ToolEvent(
133
- eventId: '$baseId.approved',
155
+ _events.add(
156
+ ToolEvent(
157
+ eventId: '$baseId.running',
158
timestamp: DateTime.now(),
135
- status: ToolEventStatus.approved,
159
+ status: ToolEventStatus.running,
160
toolName: call.name,
161
providerType: providerType,
162
providerRef: providerRef,
163
arguments: call.arguments,
140
- ));
141
- }
142
-
143
- _events.add(ToolEvent(
144
- eventId: '$baseId.running',
145
- timestamp: DateTime.now(),
146
- status: ToolEventStatus.running,
147
- toolName: call.name,
148
- providerType: providerType,
149
- providerRef: providerRef,
150
- arguments: call.arguments,
151
- ));
164
+ ),
165
+ );
166
167
final stopwatch = Stopwatch()..start();
168
try {
@@ -158,32 +172,38 @@ class ToolExecutionLoop {
172
173
stopwatch.stop();
174
161
- _events.add(ToolEvent(
162
- eventId: '$baseId.${result.success ? "completed" : "failed"}',
163
- timestamp: DateTime.now(),
164
- status: result.success ? ToolEventStatus.completed : ToolEventStatus.failed,
165
- toolName: call.name,
166
- providerType: providerType,
167
- providerRef: providerRef,
168
- arguments: call.arguments,
169
- result: result.success ? result.output : null,
170
- error: result.success ? null : result.error,
171
- durationMs: stopwatch.elapsedMilliseconds,
172
- ));
175
+ _events.add(
176
+ ToolEvent(
177
+ eventId: '$baseId.${result.success ? "completed" : "failed"}',
178
+ timestamp: DateTime.now(),
179
+ status: result.success
180
+ ? ToolEventStatus.completed
181
+ : ToolEventStatus.failed,
182
+ toolName: call.name,
183
+ providerType: providerType,
184
+ providerRef: providerRef,
185
+ arguments: call.arguments,
186
+ result: result.success ? result.output : null,
187
+ error: result.success ? null : result.error,
188
+ durationMs: stopwatch.elapsedMilliseconds,
189
+ ),
190
+ );
191
} catch (e) {
192
stopwatch.stop();
193
176
- _events.add(ToolEvent(
177
- eventId: '$baseId.failed',
178
- timestamp: DateTime.now(),
179
- status: ToolEventStatus.failed,
180
- toolName: call.name,
181
- providerType: providerType,
182
- providerRef: providerRef,
183
- arguments: call.arguments,
184
- error: 'Timeout or error: $e',
185
- durationMs: stopwatch.elapsedMilliseconds,
186
- ));
194
+ _events.add(
195
+ ToolEvent(
196
+ eventId: '$baseId.failed',
197
+ timestamp: DateTime.now(),
198
+ status: ToolEventStatus.failed,
199
+ toolName: call.name,
200
+ providerType: providerType,
201
+ providerRef: providerRef,
202
+ arguments: call.arguments,
203
+ error: 'Timeout or error: $e',
204
+ durationMs: stopwatch.elapsedMilliseconds,
205
+ ),
206
+ );
207
}
208
}
209
lib/features/chat/providers/chat_mcp_providers.dart
+35
-23
@@ -11,10 +11,7 @@ class ChatMcpConfigNotifier extends Notifier<ChatMcpConfig> {
11
@override
12
ChatMcpConfig build() {
13
final savedIntegrations = ref.read(settingsProvider).savedMcpIntegrations;
14
- return ChatMcpConfig(
15
- enabled: false,
16
- integrations: savedIntegrations,
17
- );
14
+ return ChatMcpConfig(enabled: false, integrations: savedIntegrations);
15
}
16
17
void setConfig(ChatMcpConfig config) {
@@ -23,11 +20,13 @@ class ChatMcpConfigNotifier extends Notifier<ChatMcpConfig> {
20
manager.clear();
21
for (final integration in config.integrations) {
22
if (integration.serverLabel != null && integration.serverUrl != null) {
26
- manager.addServer(
27
- integration.serverLabel!,
28
- integration.serverUrl!,
29
- headers: integration.headers,
30
- ).catchError((_) {});
23
+ manager
24
+ .addServer(
25
+ integration.serverLabel!,
26
+ integration.serverUrl!,
27
+ headers: integration.headers,
28
+ )
29
+ .catchError((_) {});
30
}
31
}
32
}
@@ -55,11 +54,14 @@ class ChatMcpConfigNotifier extends Notifier<ChatMcpConfig> {
54
: state.activeMcpServers,
55
);
56
if (integration.serverLabel != null && integration.serverUrl != null) {
58
- ref.read(mcpServerManagerProvider).addServer(
59
- integration.serverLabel!,
60
- integration.serverUrl!,
61
- headers: integration.headers,
62
- ).catchError((_) {});
57
+ ref
58
+ .read(mcpServerManagerProvider)
59
+ .addServer(
60
+ integration.serverLabel!,
61
+ integration.serverUrl!,
62
+ headers: integration.headers,
63
+ )
64
+ .catchError((_) {});
65
}
66
ref.read(settingsProvider.notifier).addSavedMcpIntegration(integration);
67
}
@@ -117,9 +119,13 @@ class ChatMcpConfigNotifier extends Notifier<ChatMcpConfig> {
119
ref.read(mcpServerManagerProvider).removeServer(integration.serverLabel!);
120
}
121
final saved = ref.read(settingsProvider).savedMcpIntegrations;
120
- final savedIdx = saved.indexWhere((i) =>
121
- (integration.pluginId != null && i.pluginId == integration.pluginId) ||
122
- (integration.serverUrl != null && i.serverUrl == integration.serverUrl));
122
+ final savedIdx = saved.indexWhere(
123
+ (i) =>
124
+ (integration.pluginId != null &&
125
+ i.pluginId == integration.pluginId) ||
126
+ (integration.serverUrl != null &&
127
+ i.serverUrl == integration.serverUrl),
128
+ );
129
if (savedIdx != -1) {
130
ref.read(settingsProvider.notifier).removeSavedMcpIntegration(savedIdx);
131
}
@@ -133,11 +139,17 @@ class ChatMcpConfigNotifier extends Notifier<ChatMcpConfig> {
139
state = state.copyWith(integrations: newIntegrations);
140
141
final saved = ref.read(settingsProvider).savedMcpIntegrations;
136
- final savedIdx = saved.indexWhere((i) =>
137
- (integration.pluginId != null && i.pluginId == integration.pluginId) ||
138
- (integration.serverUrl != null && i.serverUrl == integration.serverUrl));
142
+ final savedIdx = saved.indexWhere(
143
+ (i) =>
144
+ (integration.pluginId != null &&
145
+ i.pluginId == integration.pluginId) ||
146
+ (integration.serverUrl != null &&
147
+ i.serverUrl == integration.serverUrl),
148
+ );
149
if (savedIdx != -1) {
140
- ref.read(settingsProvider.notifier).toggleSavedMcpIntegration(savedIdx, enabled);
150
+ ref
151
+ .read(settingsProvider.notifier)
152
+ .toggleSavedMcpIntegration(savedIdx, enabled);
153
}
154
}
155
@@ -188,7 +200,8 @@ class ChatMcpConfigNotifier extends Notifier<ChatMcpConfig> {
200
if (decoded is Map<String, dynamic>) {
201
if (decoded.containsKey('mcpServers') && decoded['mcpServers'] is Map) {
202
processMcpServers(decoded['mcpServers'] as Map<String, dynamic>);
191
- } else if (decoded.containsKey('integrations') && decoded['integrations'] is List) {
203
+ } else if (decoded.containsKey('integrations') &&
204
+ decoded['integrations'] is List) {
205
final list = decoded['integrations'] as List;
206
for (final item in list) {
207
if (item is String) {
@@ -245,7 +258,6 @@ class ChatMcpConfigNotifier extends Notifier<ChatMcpConfig> {
258
return addedCount;
259
}
260
248
-
261
void toggleEnabled() {
262
state = state.copyWith(enabled: !state.enabled);
263
}
lib/features/chat/providers/chat_origin_provider.dart
+3
-2
@@ -4,8 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
4
/// handler can return there instead of always starting a new chat.
5
enum ChatOrigin { none, history, savedMessages }
6
7
-final chatOriginProvider =
8
- NotifierProvider<ChatOriginNotifier, ChatOrigin>(ChatOriginNotifier.new);
7
+final chatOriginProvider = NotifierProvider<ChatOriginNotifier, ChatOrigin>(
8
+ ChatOriginNotifier.new,
9
+);
10
11
class ChatOriginNotifier extends Notifier<ChatOrigin> {
12
@override
lib/features/chat/providers/chat_params_providers.dart
+5
-3
@@ -13,8 +13,7 @@ final chatParamsProvider = Provider<ChatParameters>((ref) {
13
final activeConv = ref.watch(conv.activeConversationProvider);
14
final selectedModel = ref.watch(selectedModelProvider);
15
final reasoningConfig = ref.watch(chatReasoningConfigProvider);
16
- final modelSupportsReasoning =
17
- selectedModel?.supportsReasoning ?? false;
16
+ final modelSupportsReasoning = selectedModel?.supportsReasoning ?? false;
17
// Mandatory-reasoning models can't run with thinking off, so force it on
18
// regardless of the toggle state.
19
final reasoningEnabled = modelSupportsReasoning
@@ -73,6 +72,9 @@ final chatParamsProvider = Provider<ChatParameters>((ref) {
72
contextLength: contextLength,
73
systemPrompt: systemPrompt,
74
reasoningEnabled: reasoningEnabled,
76
- reasoningEffort: resolveEffortForModel(selectedModel, reasoningConfig.effort),
75
+ reasoningEffort: resolveEffortForModel(
76
+ selectedModel,
77
+ reasoningConfig.effort,
78
+ ),
79
);
80
});
lib/features/chat/providers/chat_reasoning_providers.dart
+14
-3
@@ -35,7 +35,11 @@ enum ReasoningEffort {
35
/// low/medium/high set is returned so existing behaviour is preserved.
36
List<ReasoningEffort> effortsForModel(List<String>? supported) {
37
if (supported == null || supported.isEmpty) {
38
- return const [ReasoningEffort.low, ReasoningEffort.medium, ReasoningEffort.high];
38
+ return const [
39
+ ReasoningEffort.low,
40
+ ReasoningEffort.medium,
41
+ ReasoningEffort.high,
42
+ ];
43
}
44
final list = ReasoningEffort.values
45
.where((e) => supported.contains(e.apiValue))
@@ -43,7 +47,11 @@ List<ReasoningEffort> effortsForModel(List<String>? supported) {
47
// Shouldn't happen (an OpenRouter model advertising only efforts we don't
48
// model), but fall back to the classic set rather than rendering nothing.
49
if (list.isEmpty) {
46
- return const [ReasoningEffort.low, ReasoningEffort.medium, ReasoningEffort.high];
50
+ return const [
51
+ ReasoningEffort.low,
52
+ ReasoningEffort.medium,
53
+ ReasoningEffort.high,
54
+ ];
55
}
56
return list;
57
}
@@ -52,7 +60,10 @@ List<ReasoningEffort> effortsForModel(List<String>? supported) {
60
/// the model's advertised default when [current] isn't supported. Used when
61
/// building request params so a stale UI config never produces an invalid
62
/// `reasoning_effort` (e.g. `low` for a model that only supports `high`).
55
-ReasoningEffort resolveEffortForModel(ModelInfo? model, ReasoningEffort current) {
63
+ReasoningEffort resolveEffortForModel(
64
+ ModelInfo? model,
65
+ ReasoningEffort current,
66
+) {
67
final supported = model?.supportedReasoningEfforts;
68
if (supported == null || supported.isEmpty) return current;
69
if (supported.contains(current.apiValue)) return current;
lib/features/chat/providers/model_loading_providers.dart
+2
-2
@@ -27,8 +27,8 @@ class ModelLoadingState {
27
28
final modelLoadingProvider =
29
NotifierProvider<ModelLoadingNotifier, ModelLoadingState>(() {
30
- return ModelLoadingNotifier();
31
-});
30
+ return ModelLoadingNotifier();
31
+ });
32
33
class ModelLoadingNotifier extends Notifier<ModelLoadingState> {
34
@override
lib/features/chat/providers/smart_reply_providers.dart
+4
-3
@@ -15,6 +15,9 @@ final smartRepliesProvider = FutureProvider<List<String>>((ref) async {
15
final isStreaming = ref.watch(chatProvider.select((s) => s.isStreaming));
16
final settings = ref.watch(settingsProvider);
17
final voiceState = ref.watch(voiceModeProvider);
18
+ final target = ref.watch(activeChatTargetProvider);
19
+ final chatParams = ref.watch(chatParamsProvider);
20
+ final chatService = ref.watch(chatServiceProvider);
21
22
if (isStreaming) return [];
23
if (voiceState.isActive || voiceState.phase != VoiceModePhase.idle) return [];
@@ -59,11 +62,8 @@ final smartRepliesProvider = FutureProvider<List<String>>((ref) async {
62
List<String> suggestions = [];
63
64
if (settings.smartReplyEnabled) {
62
- final target = ref.watch(activeChatTargetProvider);
65
final server = target.server;
66
final modelId = target.effectiveModelId;
65
- final chatParams = ref.watch(chatParamsProvider);
66
- final chatService = ref.watch(chatServiceProvider);
67
68
if (server != null && chatService != null && modelId != null) {
69
final service = ref.read(smartReplyServiceProvider);
@@ -82,6 +82,7 @@ final smartRepliesProvider = FutureProvider<List<String>>((ref) async {
82
}
83
84
if (suggestions.isEmpty) {
85
+ if (!ref.mounted) return [];
86
final service = ref.read(smartReplyServiceProvider);
87
suggestions = service.getFallbackReplies(lastMessage.content);
88
}
lib/features/chat/providers/tooling_providers.dart
+1
-3
@@ -7,9 +7,7 @@ import '../data/mcp_server_manager.dart';
7
8
final mcpServerManagerProvider = Provider<McpServerManager>((ref) {
9
final packageInfo = ref.watch(packageInfoProvider);
10
- return McpServerManager(
11
- appVersion: packageInfo.value?.version ?? '1.0.0',
12
- );
10
+ return McpServerManager(appVersion: packageInfo.value?.version ?? '1.0.0');
11
});
12
13
final builtInToolProviderProvider = Provider<BuiltInToolProvider>((ref) {
lib/features/chat/utils/image_upload_utils.dart
+28
-8
@@ -20,12 +20,26 @@ class ImageUploadUtils {
20
static const maxBase64Chars = 1000000;
21
22
static const Map<ImageCompressionLevel, _CompressionConfig> _configs = {
23
- ImageCompressionLevel.low:
24
- _CompressionConfig(1500000, [1920, 1536, 1280, 1024]),
25
- ImageCompressionLevel.medium:
26
- _CompressionConfig(750000, [1536, 1280, 1024, 768, 512]),
27
- ImageCompressionLevel.high:
28
- _CompressionConfig(400000, [1024, 768, 512, 384, 256]),
23
+ ImageCompressionLevel.low: _CompressionConfig(1500000, [
24
+ 1920,
25
+ 1536,
26
+ 1280,
27
+ 1024,
28
+ ]),
29
+ ImageCompressionLevel.medium: _CompressionConfig(750000, [
30
+ 1536,
31
+ 1280,
32
+ 1024,
33
+ 768,
34
+ 512,
35
+ ]),
36
+ ImageCompressionLevel.high: _CompressionConfig(400000, [
37
+ 1024,
38
+ 768,
39
+ 512,
40
+ 384,
41
+ 256,
42
+ ]),
43
};
44
45
static Future<Uint8List> prepareImageBytes(
@@ -79,10 +93,16 @@ class ImageUploadUtils {
93
int targetWidth, targetHeight;
94
if (origWidth >= origHeight) {
95
targetWidth = maxDimension;
82
- targetHeight = (origHeight * maxDimension / origWidth).round().clamp(1, maxDimension);
96
+ targetHeight = (origHeight * maxDimension / origWidth).round().clamp(
97
+ 1,
98
+ maxDimension,
99
+ );
100
} else {
101
targetHeight = maxDimension;
85
- targetWidth = (origWidth * maxDimension / origHeight).round().clamp(1, maxDimension);
102
+ targetWidth = (origWidth * maxDimension / origHeight).round().clamp(
103
+ 1,
104
+ maxDimension,
105
+ );
106
}
107
final codec = await ui.instantiateImageCodec(
108
bytes,
lib/features/chat/utils/message_variants.dart
+3
-5
@@ -3,8 +3,8 @@ import '../data/models/message.dart';
3
class MessageVariants {
4
static String groupId(Message message) =>
5
message.variantGroupId?.isNotEmpty == true
6
- ? message.variantGroupId!
7
- : message.id;
6
+ ? message.variantGroupId!
7
+ : message.id;
8
9
static List<Message> resolveActiveTimeline(List<Message> allMessages) {
10
if (allMessages.isEmpty) return [];
@@ -126,9 +126,7 @@ class MessageVariants {
126
Message message,
127
) {
128
final gid = groupId(message);
129
- return allMessages
130
- .where((m) => groupId(m) == gid)
131
- .toList()
129
+ return allMessages.where((m) => groupId(m) == gid).toList()
130
..sort((a, b) => a.variantIndex.compareTo(b.variantIndex));
131
}
132
lib/features/chat/views/chat_screen.dart
-1
@@ -430,4 +430,3 @@ void _handleChatModeAction(
430
FocusManager.instance.primaryFocus?.unfocus();
431
ref.read(chatProvider.notifier).startNewConversation();
432
}
433
-
lib/features/chat/views/components/attach_sheet.dart
+7
-14
@@ -38,8 +38,7 @@ class _AttachSheet extends StatelessWidget {
38
? AppColors.darkSurfaceInput
39
: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.45);
40
final fg = isDark ? AppColors.darkPrimaryText : AppColors.lightPrimaryText;
41
- final mutedFg =
42
- isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
41
+ final mutedFg = isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
42
43
return Padding(
44
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
@@ -89,10 +88,7 @@ class _AttachSheet extends StatelessWidget {
88
const SizedBox(height: 2),
89
Text(
90
l10n.choose_attachment_subtitle,
92
- style: TextStyle(
93
- fontSize: 13,
94
- color: mutedFg,
95
- ),
91
+ style: TextStyle(fontSize: 13, color: mutedFg),
92
),
93
],
94
),
@@ -144,8 +140,9 @@ class _AttachSheet extends StatelessWidget {
140
tileColor: tileBg,
141
foreground: fg,
142
accent: const Color(0xFF22C55E),
147
- onTap: () => Navigator.of(context)
148
- .pop(AttachAction.savedMessage),
143
+ onTap: () => Navigator.of(
144
+ context,
145
+ ).pop(AttachAction.savedMessage),
146
),
147
),
148
],
@@ -200,11 +197,7 @@ class _AttachTile extends StatelessWidget {
197
borderRadius: BorderRadius.circular(12),
198
),
199
child: Center(
203
- child: HugeIcon(
204
- icon: icon,
205
- color: accent,
206
- size: 22,
207
- ),
200
+ child: HugeIcon(icon: icon, color: accent, size: 22),
201
),
202
),
203
const SizedBox(height: 8),
@@ -224,4 +217,4 @@ class _AttachTile extends StatelessWidget {
217
),
218
);
219
}
227
-}
\ No newline at end of file
220
+}
lib/features/chat/views/components/audio_player_widget.dart
+13
-11
@@ -9,13 +9,10 @@ class AudioPlayerWidget extends StatefulWidget {
9
final String source;
10
final double? height;
11
12
- const AudioPlayerWidget({
13
- super.key,
14
- required this.source,
15
- this.height,
16
- });
12
+ const AudioPlayerWidget({super.key, required this.source, this.height});
13
18
- bool get isUrl => source.startsWith('http://') || source.startsWith('https://');
14
+ bool get isUrl =>
15
+ source.startsWith('http://') || source.startsWith('https://');
16
17
@override
18
State<AudioPlayerWidget> createState() => _AudioPlayerWidgetState();
@@ -96,8 +93,10 @@ class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
93
mainAxisSize: MainAxisSize.min,
94
children: [
95
IconButton(
99
- icon: HugeIcon(icon:
100
- _state == PlayerState.playing ? HugeIcons.strokeRoundedPauseCircle : HugeIcons.strokeRoundedPlayCircle,
96
+ icon: HugeIcon(
97
+ icon: _state == PlayerState.playing
98
+ ? HugeIcons.strokeRoundedPauseCircle
99
+ : HugeIcons.strokeRoundedPlayCircle,
100
size: 28,
101
color: isDark ? Colors.white : Colors.black87,
102
),
@@ -138,9 +137,12 @@ class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
137
borderRadius: BorderRadius.circular(2),
138
child: LinearProgressIndicator(
139
value: _duration.inMilliseconds > 0
141
- ? _position.inMilliseconds / _duration.inMilliseconds
140
+ ? _position.inMilliseconds /
141
+ _duration.inMilliseconds
142
: 0,
143
- backgroundColor: isDark ? Colors.white12 : Colors.black12,
143
+ backgroundColor: isDark
144
+ ? Colors.white12
145
+ : Colors.black12,
146
valueColor: AlwaysStoppedAnimation<Color>(
147
isDark ? Colors.white60 : Colors.black54,
148
),
@@ -165,4 +167,4 @@ class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
167
),
168
);
169
}
168
-}
\ No newline at end of file
170
+}
lib/features/chat/views/components/chat_bubble/animated_bubble.dart
+8
-4
@@ -2,7 +2,11 @@ import 'package:cue/cue.dart';
2
import 'package:flutter/material.dart';
3
4
class AnimatedBubble extends StatelessWidget {
5
- const AnimatedBubble({super.key, required this.child, required this.alignment});
5
+ const AnimatedBubble({
6
+ super.key,
7
+ required this.child,
8
+ required this.alignment,
9
+ });
10
11
final Widget child;
12
final AlignmentDirectional alignment;
@@ -16,8 +20,8 @@ class AnimatedBubble extends StatelessWidget {
20
final Offset translateOffset = isUserRight
21
? const Offset(12, 0)
22
: isAssistantLeft
19
- ? const Offset(-12, 0)
20
- : const Offset(0, 8);
23
+ ? const Offset(-12, 0)
24
+ : const Offset(0, 8);
25
26
return Cue.onMount(
27
motion: .smooth(),
@@ -29,4 +33,4 @@ class AnimatedBubble extends StatelessWidget {
33
child: child,
34
);
35
}
32
-}
\ No newline at end of file
36
+}
lib/features/chat/views/components/chat_bubble/attachment_list.dart
+17
-10
@@ -37,9 +37,9 @@ class _AttachmentItem extends StatelessWidget {
37
if (!context.mounted) return;
38
if (text == null) {
39
final l10n = AppLocalizations.of(context)!;
40
- ScaffoldMessenger.of(context).showSnackBar(
41
- SnackBar(content: Text(l10n.could_not_read_file)),
42
- );
40
+ ScaffoldMessenger.of(
41
+ context,
42
+ ).showSnackBar(SnackBar(content: Text(l10n.could_not_read_file)));
43
return;
44
}
45
showModalBottomSheet<void>(
@@ -92,8 +92,8 @@ class _AttachmentItem extends StatelessWidget {
92
child: Row(
93
mainAxisSize: MainAxisSize.min,
94
children: [
95
- HugeIcon(icon:
96
- HugeIcons.strokeRoundedFile01,
95
+ HugeIcon(
96
+ icon: HugeIcons.strokeRoundedFile01,
97
size: 20,
98
color: theme.colorScheme.primary,
99
),
@@ -139,8 +139,8 @@ class _AttachmentItem extends StatelessWidget {
139
child: Row(
140
mainAxisSize: MainAxisSize.min,
141
children: [
142
- HugeIcon(icon:
143
- HugeIcons.strokeRoundedFile01,
142
+ HugeIcon(
143
+ icon: HugeIcons.strokeRoundedFile01,
144
size: 20,
145
color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
146
),
@@ -189,7 +189,9 @@ class _TextPreviewSheet extends StatelessWidget {
189
width: 32,
190
height: 4,
191
decoration: BoxDecoration(
192
- color: isDark ? const Color(0xFF444444) : const Color(0xFFDDDDDD),
192
+ color: isDark
193
+ ? const Color(0xFF444444)
194
+ : const Color(0xFFDDDDDD),
195
borderRadius: BorderRadius.circular(2),
196
),
197
),
@@ -197,7 +199,10 @@ class _TextPreviewSheet extends StatelessWidget {
199
padding: const EdgeInsets.all(16),
200
child: Row(
201
children: [
200
- HugeIcon(icon: HugeIcons.strokeRoundedFile01, color: theme.colorScheme.primary),
202
+ HugeIcon(
203
+ icon: HugeIcons.strokeRoundedFile01,
204
+ color: theme.colorScheme.primary,
205
+ ),
206
const SizedBox(width: 8),
207
Expanded(
208
child: Text(
@@ -210,7 +215,9 @@ class _TextPreviewSheet extends StatelessWidget {
215
),
216
),
217
IconButton(
213
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedCancel01),
218
+ icon: const HugeIcon(
219
+ icon: HugeIcons.strokeRoundedCancel01,
220
+ ),
221
onPressed: () => Navigator.pop(context),
222
),
223
],
lib/features/chat/views/components/chat_bubble/chat_error_display.dart
+1
-4
@@ -2,10 +2,7 @@ import 'package:flutter/material.dart';
2
import 'package:localmind/features/chat/data/chat_api_error.dart';
3
4
class ChatErrorDisplay extends StatelessWidget {
5
- const ChatErrorDisplay({
6
- super.key,
7
- required this.errorMessage,
8
- });
5
+ const ChatErrorDisplay({super.key, required this.errorMessage});
6
7
final String errorMessage;
8
lib/features/chat/views/components/chat_bubble/markdown/deferred_markdown.dart
+9
-3
@@ -4,13 +4,18 @@ import 'themed_gpt_markdown.dart';
4
import 'streaming_text.dart';
5
6
class DeferredMarkdownContent extends StatefulWidget {
7
- const DeferredMarkdownContent({super.key, required this.content, required this.isDark});
7
+ const DeferredMarkdownContent({
8
+ super.key,
9
+ required this.content,
10
+ required this.isDark,
11
+ });
12
13
final String content;
14
final bool isDark;
15
16
@override
13
- State<DeferredMarkdownContent> createState() => _DeferredMarkdownContentState();
17
+ State<DeferredMarkdownContent> createState() =>
18
+ _DeferredMarkdownContentState();
19
}
20
21
class _DeferredMarkdownContentState extends State<DeferredMarkdownContent> {
@@ -36,7 +41,8 @@ class _DeferredMarkdownContentState extends State<DeferredMarkdownContent> {
41
@override
42
void didUpdateWidget(DeferredMarkdownContent oldWidget) {
43
super.didUpdateWidget(oldWidget);
39
- if (oldWidget.content != widget.content || oldWidget.isDark != widget.isDark) {
44
+ if (oldWidget.content != widget.content ||
45
+ oldWidget.isDark != widget.isDark) {
46
_showMarkdown = false;
47
_deferTimer?.cancel();
48
_deferTimer = Timer(const Duration(milliseconds: 120), () {
lib/features/chat/views/components/chat_bubble/markdown/streaming_text.dart
+5
-1
@@ -1,7 +1,11 @@
1
import 'package:flutter/material.dart';
2
3
class StreamingTextContent extends StatelessWidget {
4
- const StreamingTextContent({super.key, required this.content, required this.isDark});
4
+ const StreamingTextContent({
5
+ super.key,
6
+ required this.content,
7
+ required this.isDark,
8
+ });
9
10
final String content;
11
final bool isDark;
lib/features/chat/views/components/chat_bubble/markdown/themed_gpt_markdown.dart
+26
-7
@@ -62,7 +62,9 @@ class ThemedGptMarkdown extends StatelessWidget {
62
Widget build(BuildContext context) {
63
final gptTheme = GptMarkdownThemeData(
64
brightness: isDark ? Brightness.dark : Brightness.light,
65
- highlightColor: isDark ? const Color(0xFF334155) : const Color(0xFFDBEAFE),
65
+ highlightColor: isDark
66
+ ? const Color(0xFF334155)
67
+ : const Color(0xFFDBEAFE),
68
linkColor: isDark ? AppColors.darkAccent : AppColors.lightAccent,
69
linkHoverColor: isDark ? AppColors.darkAccent : AppColors.lightAccent,
70
hrLineColor: isDark ? const Color(0xFF334155) : const Color(0xFFE2E8F0),
@@ -98,7 +100,11 @@ class ThemedGptMarkdown extends StatelessWidget {
100
}
101
102
Widget _buildImageOrAudio(
101
- BuildContext context, String url, double? width, double? height) {
103
+ BuildContext context,
104
+ String url,
105
+ double? width,
106
+ double? height,
107
+ ) {
108
if (_isAudioUrl(url)) {
109
return AudioPlayerWidget(source: url, height: 56);
110
}
@@ -113,14 +119,19 @@ class ThemedGptMarkdown extends StatelessWidget {
119
child: CircularProgressIndicator(
120
value: loadingProgress.expectedTotalBytes != null
121
? loadingProgress.cumulativeBytesLoaded /
116
- loadingProgress.expectedTotalBytes!
122
+ loadingProgress.expectedTotalBytes!
123
: null,
124
),
125
);
126
},
127
errorBuilder: (context, error, stackTrace) => Container(
128
color: Colors.grey[800],
123
- child: const Center(child: HugeIcon(icon: HugeIcons.strokeRoundedImageNotFound01, color: Colors.white54)),
129
+ child: const Center(
130
+ child: HugeIcon(
131
+ icon: HugeIcons.strokeRoundedImageNotFound01,
132
+ color: Colors.white54,
133
+ ),
134
+ ),
135
),
136
),
137
);
@@ -128,7 +139,11 @@ class ThemedGptMarkdown extends StatelessWidget {
139
}
140
141
class MarkdownContent extends StatelessWidget {
131
- const MarkdownContent({super.key, required this.content, required this.isDark});
142
+ const MarkdownContent({
143
+ super.key,
144
+ required this.content,
145
+ required this.isDark,
146
+ });
147
148
final String content;
149
final bool isDark;
@@ -143,7 +158,11 @@ class MarkdownContent extends StatelessWidget {
158
}
159
160
class MarkdownBodyContent extends StatelessWidget {
146
- const MarkdownBodyContent({super.key, required this.content, required this.isDark});
161
+ const MarkdownBodyContent({
162
+ super.key,
163
+ required this.content,
164
+ required this.isDark,
165
+ });
166
167
final String content;
168
final bool isDark;
@@ -162,4 +181,4 @@ class MarkdownBodyContent extends StatelessWidget {
181
),
182
);
183
}
165
-}
\ No newline at end of file
184
+}
lib/features/chat/views/components/chat_bubble/tool_bubble/tool_bubble.dart
+9
-5
@@ -40,10 +40,12 @@ class ToolBubble extends StatelessWidget {
40
children: [
41
Row(
42
children: [
43
- HugeIcon(icon:
44
- HugeIcons.strokeRoundedTools,
43
+ HugeIcon(
44
+ icon: HugeIcons.strokeRoundedTools,
45
size: 14,
46
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
46
+ color: isDark
47
+ ? AppColors.darkMutedText
48
+ : AppColors.lightMutedText,
49
),
50
const SizedBox(width: 4),
51
Text(
@@ -53,7 +55,9 @@ class ToolBubble extends StatelessWidget {
55
style: TextStyle(
56
fontSize: 12,
57
fontWeight: FontWeight.w500,
56
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
58
+ color: isDark
59
+ ? AppColors.darkMutedText
60
+ : AppColors.lightMutedText,
61
),
62
),
63
],
@@ -72,4 +76,4 @@ class ToolBubble extends StatelessWidget {
76
),
77
);
78
}
75
-}
\ No newline at end of file
79
+}
lib/features/chat/views/components/chat_bubble/tool_bubble/tool_row.dart
+18
-10
@@ -99,16 +99,20 @@ class _ToolRowWidgetState extends State<ToolRowWidget> {
99
if (call.status == ToolEventStatus.running)
100
_AnimatedRunningIcon(color: iconColor)
101
else
102
- HugeIcon(icon:
103
- switch (call.status) {
102
+ HugeIcon(
103
+ icon: switch (call.status) {
104
ToolEventStatus.requested =>
105
HugeIcons.strokeRoundedClock01,
106
ToolEventStatus.approved =>
107
HugeIcons.strokeRoundedCheckmarkCircle01,
108
- ToolEventStatus.rejected => HugeIcons.strokeRoundedUnavailable,
109
- ToolEventStatus.running => HugeIcons.strokeRoundedRefresh,
110
- ToolEventStatus.completed => HugeIcons.strokeRoundedCheckmarkCircle01,
111
- ToolEventStatus.failed => HugeIcons.strokeRoundedAlertCircle,
108
+ ToolEventStatus.rejected =>
109
+ HugeIcons.strokeRoundedUnavailable,
110
+ ToolEventStatus.running =>
111
+ HugeIcons.strokeRoundedRefresh,
112
+ ToolEventStatus.completed =>
113
+ HugeIcons.strokeRoundedCheckmarkCircle01,
114
+ ToolEventStatus.failed =>
115
+ HugeIcons.strokeRoundedAlertCircle,
116
},
117
size: 14,
118
color: iconColor,
@@ -187,8 +191,8 @@ class _ToolRowWidgetState extends State<ToolRowWidget> {
191
],
192
if (showCodeBlock) ...[
193
const SizedBox(width: 6),
190
- HugeIcon(icon:
191
- _isExpanded
194
+ HugeIcon(
195
+ icon: _isExpanded
196
? HugeIcons.strokeRoundedArrowUp01
197
: HugeIcons.strokeRoundedArrowDown01,
198
size: 16,
@@ -342,7 +346,11 @@ class _AnimatedRunningIconState extends State<_AnimatedRunningIcon>
346
Widget build(BuildContext context) {
347
return RotationTransition(
348
turns: _controller,
345
- child: HugeIcon(icon: HugeIcons.strokeRoundedRefresh, size: 14, color: widget.color),
349
+ child: HugeIcon(
350
+ icon: HugeIcons.strokeRoundedRefresh,
351
+ size: 14,
352
+ color: widget.color,
353
+ ),
354
);
355
}
348
-}
\ No newline at end of file
356
+}
lib/features/chat/views/components/chat_bubble/tool_bubble/tool_timeline.dart
+16
-7
@@ -107,10 +107,12 @@ class ToolTimeline extends StatelessWidget {
107
),
108
child: Row(
109
children: [
110
- HugeIcon(icon:
111
- HugeIcons.strokeRoundedComputerTerminal01,
110
+ HugeIcon(
111
+ icon: HugeIcons.strokeRoundedComputerTerminal01,
112
size: 16,
113
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
113
+ color: isDark
114
+ ? AppColors.darkMutedText
115
+ : AppColors.lightMutedText,
116
),
117
const SizedBox(width: 8),
118
Text(
@@ -119,14 +121,21 @@ class ToolTimeline extends StatelessWidget {
121
fontSize: 11,
122
fontWeight: FontWeight.bold,
123
letterSpacing: 0.5,
122
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
124
+ color: isDark
125
+ ? AppColors.darkMutedText
126
+ : AppColors.lightMutedText,
127
),
128
),
129
const Spacer(),
130
Container(
127
- padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
131
+ padding: const EdgeInsets.symmetric(
132
+ horizontal: 6,
133
+ vertical: 2,
134
+ ),
135
decoration: BoxDecoration(
129
- color: isDark ? AppColors.darkSurfaceCard : AppColors.lightBorder,
136
+ color: isDark
137
+ ? AppColors.darkSurfaceCard
138
+ : AppColors.lightBorder,
139
borderRadius: BorderRadius.circular(6),
140
),
141
child: Text(
@@ -170,4 +179,4 @@ class ToolTimeline extends StatelessWidget {
179
),
180
);
181
}
173
-}
\ No newline at end of file
182
+}
lib/features/chat/views/components/chat_bubble/user_bubble.dart
+18
-6
@@ -46,7 +46,9 @@ class UserBubble extends StatelessWidget {
46
children: [
47
Container(
48
constraints: BoxConstraints(
49
- maxWidth: maxBubbleWidth < availableWidth ? maxBubbleWidth : availableWidth,
49
+ maxWidth: maxBubbleWidth < availableWidth
50
+ ? maxBubbleWidth
51
+ : availableWidth,
52
),
53
margin: const EdgeInsetsDirectional.only(
54
start: 48,
@@ -56,7 +58,9 @@ class UserBubble extends StatelessWidget {
58
),
59
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
60
decoration: BoxDecoration(
59
- color: isDark ? AppColors.darkSurfaceCard : AppColors.lightSurface,
61
+ color: isDark
62
+ ? AppColors.darkSurfaceCard
63
+ : AppColors.lightSurface,
64
borderRadius: BorderRadiusDirectional.only(
65
topStart: Radius.circular(18),
66
topEnd: Radius.circular(18),
@@ -81,7 +85,9 @@ class UserBubble extends StatelessWidget {
85
content: message.content,
86
isDark: isDark,
87
style: TextStyle(
84
- color: isDark ? AppColors.darkPrimaryText : AppColors.lightPrimaryText,
88
+ color: isDark
89
+ ? AppColors.darkPrimaryText
90
+ : AppColors.lightPrimaryText,
91
fontSize: 16,
92
height: 1.6,
93
),
@@ -108,12 +114,18 @@ class UserBubble extends StatelessWidget {
114
_formatTime(message.createdAt),
115
style: TextStyle(
116
fontSize: 11,
111
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
117
+ color: isDark
118
+ ? AppColors.darkMutedText
119
+ : AppColors.lightMutedText,
120
),
121
),
122
if (message.status == MessageStatus.error) ...[
123
const SizedBox(width: 4),
116
- HugeIcon(icon: HugeIcons.strokeRoundedInformationCircle, size: 14, color: Colors.red[200]),
124
+ HugeIcon(
125
+ icon: HugeIcons.strokeRoundedInformationCircle,
126
+ size: 14,
127
+ color: Colors.red[200],
128
+ ),
129
],
130
const SizedBox(width: 8),
131
MessageActionBar(
@@ -141,4 +153,4 @@ String _formatTime(DateTime dateTime) {
153
final hour = dateTime.hour.toString().padLeft(2, '0');
154
final minute = dateTime.minute.toString().padLeft(2, '0');
155
return '$hour:$minute';
144
-}
\ No newline at end of file
156
+}
lib/features/chat/views/components/chat_settings_sheet.dart
+13
-14
@@ -100,7 +100,10 @@ class _ChatSettingsSheetState extends ConsumerState<ChatSettingsSheet> {
100
if (hasOverrides)
101
ShadButton.ghost(
102
onPressed: () => _resetToDefaults(ref, activeConv?.id),
103
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedRefresh, size: 16),
103
+ leading: const HugeIcon(
104
+ icon: HugeIcons.strokeRoundedRefresh,
105
+ size: 16,
106
+ ),
107
child: Text(l10n.reset_defaults),
108
),
109
],
@@ -231,8 +234,8 @@ class _ChatSettingsSheetState extends ConsumerState<ChatSettingsSheet> {
234
),
235
child: Row(
236
children: [
234
- const HugeIcon(icon:
235
- HugeIcons.strokeRoundedAlertCircle,
237
+ const HugeIcon(
238
+ icon: HugeIcons.strokeRoundedAlertCircle,
239
color: Colors.orange,
240
size: 20,
241
),
@@ -357,9 +360,7 @@ class _ChatSettingsSheetState extends ConsumerState<ChatSettingsSheet> {
360
leading: HugeIcon(
361
icon: HugeIcons.strokeRoundedPuzzle,
362
size: 18,
360
- color: integration.enabled
361
- ? null
362
- : Colors.grey,
363
+ color: integration.enabled ? null : Colors.grey,
364
),
365
title: Text(
366
integration.serverLabel ?? integration.pluginId ?? '',
@@ -371,8 +372,8 @@ class _ChatSettingsSheetState extends ConsumerState<ChatSettingsSheet> {
372
color: integration.enabled
373
? null
374
: (isDark
374
- ? AppColors.darkMutedText
375
- : AppColors.lightMutedText),
375
+ ? AppColors.darkMutedText
376
+ : AppColors.lightMutedText),
377
),
378
),
379
subtitle: Text(
@@ -430,10 +431,7 @@ class _ChatSettingsSheetState extends ConsumerState<ChatSettingsSheet> {
431
),
432
title: Row(
433
children: [
433
- const HugeIcon(
434
- icon: HugeIcons.strokeRoundedFileImport,
435
- size: 22,
436
- ),
434
+ const HugeIcon(icon: HugeIcons.strokeRoundedFileImport, size: 22),
435
const SizedBox(width: 8),
436
Expanded(
437
child: Text(
@@ -507,8 +505,9 @@ class _ChatSettingsSheetState extends ConsumerState<ChatSettingsSheet> {
505
? l10n.mcp_import_success(importedCount)
506
: l10n.mcp_import_failed,
507
),
510
- backgroundColor:
511
- importedCount > 0 ? Colors.green : Colors.red,
508
+ backgroundColor: importedCount > 0
509
+ ? Colors.green
510
+ : Colors.red,
511
),
512
);
513
}
lib/features/chat/views/components/edit_message_dialog.dart
+5
-7
@@ -43,10 +43,8 @@ class EditMessageDialog extends StatefulWidget {
43
context: context,
44
isScrollControlled: true,
45
backgroundColor: Colors.transparent,
46
- builder: (context) => EditMessageDialog(
47
- initialContent: initialContent,
48
- showSaveOnly: true,
49
- ),
46
+ builder: (context) =>
47
+ EditMessageDialog(initialContent: initialContent, showSaveOnly: true),
48
);
49
}
50
@@ -89,9 +87,9 @@ class _EditMessageDialogState extends State<EditMessageDialog> {
87
final text = _validatedText();
88
if (text == null) return;
89
if (widget.showSaveOnly) {
92
- Navigator.of(context).pop(
93
- EditMessageResult(content: text, regenerate: regenerate),
94
- );
90
+ Navigator.of(
91
+ context,
92
+ ).pop(EditMessageResult(content: text, regenerate: regenerate));
93
} else {
94
Navigator.of(context).pop(text);
95
}
lib/features/chat/views/components/edit_message_result.dart
+1
-4
@@ -1,8 +1,5 @@
1
class EditMessageResult {
2
- const EditMessageResult({
3
- required this.content,
4
- required this.regenerate,
5
- });
2
+ const EditMessageResult({required this.content, required this.regenerate});
3
4
final String content;
5
final bool regenerate;
lib/features/chat/views/components/image_preview_dialog.dart
+5
-2
@@ -24,7 +24,10 @@ class _ImagePreviewDialog extends StatelessWidget {
24
backgroundColor: Colors.transparent,
25
elevation: 0,
26
leading: IconButton(
27
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedCancel01, color: Colors.white),
27
+ icon: const HugeIcon(
28
+ icon: HugeIcons.strokeRoundedCancel01,
29
+ color: Colors.white,
30
+ ),
31
onPressed: () => Navigator.pop(context),
32
),
33
),
@@ -37,4 +40,4 @@ class _ImagePreviewDialog extends StatelessWidget {
40
),
41
);
42
}
40
-}
\ No newline at end of file
43
+}
lib/features/chat/views/components/message_action_bar.dart
+30
-19
@@ -125,7 +125,9 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
125
if (widget.onSave != null) ...[
126
const SizedBox(width: 4),
127
_ActionButton(
128
- icon: isSaved ? HugeIcons.strokeRoundedBookmark01 : HugeIcons.strokeRoundedBookmark01,
128
+ icon: isSaved
129
+ ? HugeIcons.strokeRoundedBookmark01
130
+ : HugeIcons.strokeRoundedBookmark01,
131
label: isSaved ? l10n.message_already_saved : l10n.save_message,
132
onTap: widget.onSave,
133
isActive: isSaved,
@@ -149,7 +151,11 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
151
],
152
if (widget.onEdit != null) ...[
153
const SizedBox(width: 4),
152
- _ActionButton(icon: HugeIcons.strokeRoundedPencilEdit02, label: l10n.edit, onTap: widget.onEdit),
154
+ _ActionButton(
155
+ icon: HugeIcons.strokeRoundedPencilEdit02,
156
+ label: l10n.edit,
157
+ onTap: widget.onEdit,
158
+ ),
159
],
160
if (widget.onDelete != null) ...[
161
const SizedBox(width: 4),
@@ -172,9 +178,11 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
178
_ActionButton(
179
icon: isThisActive
180
? (isThisPlaying
175
- ? HugeIcons.strokeRoundedPauseCircle
176
- : HugeIcons.strokeRoundedPlayCircle)
177
- : (isThisInitializing ? HugeIcons.strokeRoundedClock01 : HugeIcons.strokeRoundedVolumeUp),
181
+ ? HugeIcons.strokeRoundedPauseCircle
182
+ : HugeIcons.strokeRoundedPlayCircle)
183
+ : (isThisInitializing
184
+ ? HugeIcons.strokeRoundedClock01
185
+ : HugeIcons.strokeRoundedVolumeUp),
186
label: isThisActive
187
? (isThisPlaying ? l10n.pause : l10n.resume)
188
: (isThisInitializing ? l10n.initializing : l10n.read_aloud),
@@ -306,8 +314,8 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
314
_CompactOptionTile(
315
icon: isThisActive
316
? (isThisPlaying
309
- ? HugeIcons.strokeRoundedPauseCircle
310
- : HugeIcons.strokeRoundedPlayCircle)
317
+ ? HugeIcons.strokeRoundedPauseCircle
318
+ : HugeIcons.strokeRoundedPlayCircle)
319
: HugeIcons.strokeRoundedVolumeUp,
320
label: isThisActive
321
? (isThisPlaying ? sheetL10n.pause : sheetL10n.resume)
@@ -337,7 +345,9 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
345
),
346
if (widget.onSave != null)
347
_CompactOptionTile(
340
- icon: isSaved ? HugeIcons.strokeRoundedBookmark01 : HugeIcons.strokeRoundedBookmark01,
348
+ icon: isSaved
349
+ ? HugeIcons.strokeRoundedBookmark01
350
+ : HugeIcons.strokeRoundedBookmark01,
351
label: isSaved
352
? sheetL10n.message_already_saved
353
: sheetL10n.save_message,
@@ -353,12 +363,13 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
363
child: GridView.builder(
364
shrinkWrap: true,
365
physics: const NeverScrollableScrollPhysics(),
356
- gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
357
- crossAxisCount: 2,
358
- childAspectRatio: 3.2,
359
- crossAxisSpacing: 8,
360
- mainAxisSpacing: 4,
361
- ),
366
+ gridDelegate:
367
+ const SliverGridDelegateWithFixedCrossAxisCount(
368
+ crossAxisCount: 2,
369
+ childAspectRatio: 3.2,
370
+ crossAxisSpacing: 8,
371
+ mainAxisSpacing: 4,
372
+ ),
373
itemCount: stats.length,
374
itemBuilder: (context, index) {
375
final stat = stats[index];
@@ -485,8 +496,8 @@ class _ActionButtonState extends State<_ActionButton>
496
final baseColor = widget.isActive
497
? theme.colorScheme.primary
498
: widget.isDestructive
488
- ? (isDark ? Colors.red[300] : Colors.red[600])
489
- : (isDark ? AppColors.darkMutedText : AppColors.lightMutedText);
499
+ ? (isDark ? Colors.red[300] : Colors.red[600])
500
+ : (isDark ? AppColors.darkMutedText : AppColors.lightMutedText);
501
502
final hoverColor = widget.isDestructive
503
? (isDark ? Colors.red[200] : Colors.red[500])
@@ -520,8 +531,8 @@ class _ActionButtonState extends State<_ActionButton>
531
: Colors.transparent,
532
borderRadius: BorderRadius.circular(4),
533
),
523
- child: HugeIcon(icon:
524
- widget.icon,
534
+ child: HugeIcon(
535
+ icon: widget.icon,
536
size: 16,
537
color: _isHovered ? hoverColor : baseColor,
538
),
@@ -531,4 +542,4 @@ class _ActionButtonState extends State<_ActionButton>
542
),
543
);
544
}
534
-}
\ No newline at end of file
545
+}
lib/features/chat/views/components/message_list/corrupted_state.dart
+9
-5
@@ -29,7 +29,8 @@ class CorruptedChatState extends ConsumerWidget {
29
actions: [
30
ShadButton.outline(
31
onPressed: () {
32
- final data = '''
32
+ final data =
33
+ '''
34
ID: ${conversation.id}
35
Title: ${conversation.title}
36
Expected: ${conversation.messageCount}
@@ -123,8 +124,8 @@ Error: $errorMessage
124
color: Colors.red.withValues(alpha: 0.1),
125
shape: BoxShape.circle,
126
),
126
- child: const HugeIcon(icon:
127
- HugeIcons.strokeRoundedClock01,
127
+ child: const HugeIcon(
128
+ icon: HugeIcons.strokeRoundedClock01,
129
size: 48,
130
color: Colors.red,
131
),
@@ -153,7 +154,10 @@ Error: $errorMessage
154
children: [
155
ShadButton(
156
onPressed: onStartNewChat,
156
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedAdd01, size: 20),
157
+ leading: const HugeIcon(
158
+ icon: HugeIcons.strokeRoundedAdd01,
159
+ size: 20,
160
+ ),
161
child: Text(l10n.start_new_chat),
162
),
163
const SizedBox(width: 12),
@@ -168,4 +172,4 @@ Error: $errorMessage
172
),
173
);
174
}
171
-}
\ No newline at end of file
175
+}
lib/features/chat/views/components/message_list/empty_state.dart
+34
-16
@@ -55,11 +55,13 @@ class _EmptyStateState extends State<EmptyState> {
55
}
56
57
List<String> _welcomeMessages(AppLocalizations l10n) => [
58
- l10n.welcome_message_1,
59
- l10n.welcome_message_2,
60
- widget.isCloudProvider ? l10n.welcome_message_cloud : l10n.welcome_message_3,
61
- l10n.welcome_message_4,
62
- ];
58
+ l10n.welcome_message_1,
59
+ l10n.welcome_message_2,
60
+ widget.isCloudProvider
61
+ ? l10n.welcome_message_cloud
62
+ : l10n.welcome_message_3,
63
+ l10n.welcome_message_4,
64
+ ];
65
66
@override
67
Widget build(BuildContext context) {
@@ -138,8 +140,11 @@ class _EmptyStateState extends State<EmptyState> {
140
child: Row(
141
mainAxisSize: MainAxisSize.min,
142
children: [
141
- HugeIcon(icon: HugeIcons.strokeRoundedSettings01,
142
- size: 18, color: theme.colorScheme.primary),
143
+ HugeIcon(
144
+ icon: HugeIcons.strokeRoundedSettings01,
145
+ size: 18,
146
+ color: theme.colorScheme.primary,
147
+ ),
148
const SizedBox(width: 8),
149
Flexible(
150
child: Text(
@@ -154,7 +159,11 @@ class _EmptyStateState extends State<EmptyState> {
159
),
160
),
161
const SizedBox(width: 4),
157
- const HugeIcon(icon: HugeIcons.strokeRoundedArrowDown01, size: 16, color: Colors.grey),
162
+ const HugeIcon(
163
+ icon: HugeIcons.strokeRoundedArrowDown01,
164
+ size: 16,
165
+ color: Colors.grey,
166
+ ),
167
],
168
),
169
),
@@ -190,8 +199,11 @@ class _EmptyStateState extends State<EmptyState> {
199
),
200
),
201
] else ...[
193
- HugeIcon(icon: HugeIcons.strokeRoundedRobot01,
194
- size: 18, color: theme.colorScheme.primary),
202
+ HugeIcon(
203
+ icon: HugeIcons.strokeRoundedRobot01,
204
+ size: 18,
205
+ color: theme.colorScheme.primary,
206
+ ),
207
const SizedBox(width: 8),
208
Flexible(
209
child: Text(
@@ -207,7 +219,11 @@ class _EmptyStateState extends State<EmptyState> {
219
),
220
],
221
const SizedBox(width: 4),
210
- const HugeIcon(icon: HugeIcons.strokeRoundedArrowDown01, size: 16, color: Colors.grey),
222
+ const HugeIcon(
223
+ icon: HugeIcons.strokeRoundedArrowDown01,
224
+ size: 16,
225
+ color: Colors.grey,
226
+ ),
227
],
228
),
229
),
@@ -248,8 +264,9 @@ class _EmptyStateState extends State<EmptyState> {
264
child: ActionChip(
265
label: Text(prompt),
266
onPressed: () => widget.onQuickPrompt(prompt),
251
- backgroundColor:
252
- isDark ? AppColors.darkSurfaceCard : AppColors.lightSurface,
267
+ backgroundColor: isDark
268
+ ? AppColors.darkSurfaceCard
269
+ : AppColors.lightSurface,
270
side: BorderSide(
271
color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
272
),
@@ -270,8 +287,9 @@ class _EmptyStateState extends State<EmptyState> {
287
style: TextStyle(
288
fontSize: 14,
289
fontWeight: FontWeight.w500,
273
- color:
274
- isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
290
+ color: isDark
291
+ ? AppColors.darkMutedText
292
+ : AppColors.lightMutedText,
293
),
294
),
295
const SizedBox(width: 8),
@@ -303,4 +321,4 @@ class _EmptyStateState extends State<EmptyState> {
321
],
322
);
323
}
306
-}
\ No newline at end of file
324
+}
lib/features/chat/views/components/message_list/message_list.dart
+5
-2
@@ -354,7 +354,10 @@ class _MessageList extends ConsumerWidget {
354
alignment: Alignment.centerLeft,
355
child: OutlinedButton.icon(
356
onPressed: onGenerateResponse,
357
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedSparkles, size: 16),
357
+ icon: const HugeIcon(
358
+ icon: HugeIcons.strokeRoundedSparkles,
359
+ size: 16,
360
+ ),
361
label: Text(
362
AppLocalizations.of(context)!.generate_ai_response,
363
),
@@ -366,4 +369,4 @@ class _MessageList extends ConsumerWidget {
369
},
370
);
371
}
369
-}
\ No newline at end of file
372
+}
lib/features/chat/views/components/message_list/recent_conversation_item.dart
+14
-8
@@ -30,10 +30,12 @@ class RecentConversationItem extends ConsumerWidget {
30
),
31
child: Row(
32
children: [
33
- HugeIcon(icon:
34
- HugeIcons.strokeRoundedChatting01,
33
+ HugeIcon(
34
+ icon: HugeIcons.strokeRoundedChatting01,
35
size: 18,
36
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
36
+ color: isDark
37
+ ? AppColors.darkMutedText
38
+ : AppColors.lightMutedText,
39
),
40
const SizedBox(width: 12),
41
Expanded(
@@ -55,7 +57,9 @@ class RecentConversationItem extends ConsumerWidget {
57
conversation.lastMessagePreview!,
58
style: TextStyle(
59
fontSize: 12,
58
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
60
+ color: isDark
61
+ ? AppColors.darkMutedText
62
+ : AppColors.lightMutedText,
63
),
64
maxLines: 1,
65
overflow: TextOverflow.ellipsis,
@@ -63,16 +67,18 @@ class RecentConversationItem extends ConsumerWidget {
67
],
68
),
69
),
66
- HugeIcon(icon:
67
- Directionality.of(context) == TextDirection.rtl
70
+ HugeIcon(
71
+ icon: Directionality.of(context) == TextDirection.rtl
72
? HugeIcons.strokeRoundedArrowLeft01
73
: HugeIcons.strokeRoundedArrowRight01,
74
size: 18,
71
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
75
+ color: isDark
76
+ ? AppColors.darkMutedText
77
+ : AppColors.lightMutedText,
78
),
79
],
80
),
81
),
82
);
83
}
78
-}
\ No newline at end of file
84
+}
lib/features/chat/views/components/message_variant_navigator.dart
+15
-7
@@ -23,8 +23,7 @@ class MessageVariantNavigator extends StatelessWidget {
23
24
final currentIndex = MessageVariants.activeVariantIndex(variants);
25
final isDark = Theme.of(context).brightness == Brightness.dark;
26
- final muted =
27
- isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
26
+ final muted = isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
27
28
return Padding(
29
padding: const EdgeInsets.only(top: 6),
@@ -36,7 +35,11 @@ class MessageVariantNavigator extends StatelessWidget {
35
padding: EdgeInsets.zero,
36
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
37
onPressed: currentIndex > 0 ? () => onCycle(-1) : null,
39
- icon: HugeIcon(icon: HugeIcons.strokeRoundedArrowLeft01, size: 18, color: muted),
38
+ icon: HugeIcon(
39
+ icon: HugeIcons.strokeRoundedArrowLeft01,
40
+ size: 18,
41
+ color: muted,
42
+ ),
43
),
44
Text(
45
'${currentIndex + 1} / ${variants.length}',
@@ -50,12 +53,17 @@ class MessageVariantNavigator extends StatelessWidget {
53
visualDensity: VisualDensity.compact,
54
padding: EdgeInsets.zero,
55
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
53
- onPressed:
54
- currentIndex < variants.length - 1 ? () => onCycle(1) : null,
55
- icon: HugeIcon(icon: HugeIcons.strokeRoundedArrowRight01, size: 18, color: muted),
56
+ onPressed: currentIndex < variants.length - 1
57
+ ? () => onCycle(1)
58
+ : null,
59
+ icon: HugeIcon(
60
+ icon: HugeIcons.strokeRoundedArrowRight01,
61
+ size: 18,
62
+ color: muted,
63
+ ),
64
),
65
],
66
),
67
);
68
}
61
-}
\ No newline at end of file
69
+}
lib/features/chat/views/components/model_info_sheet.dart
+7
-13
@@ -50,14 +50,8 @@ Future<void> showModelInfoSheet(
50
child: Column(
51
mainAxisSize: MainAxisSize.min,
52
children: [
53
- _ModelInfoRow(
54
- label: l10n.model_name,
55
- value: displayName,
56
- ),
57
- _ModelInfoRow(
58
- label: l10n.model_identifier,
59
- value: identifier,
60
- ),
53
+ _ModelInfoRow(label: l10n.model_name, value: displayName),
54
+ _ModelInfoRow(label: l10n.model_identifier, value: identifier),
55
if (capabilities.isNotEmpty)
56
_ModelInfoRow(
57
label: l10n.model_capabilities,
@@ -74,7 +68,7 @@ Future<void> showModelInfoSheet(
68
value: model!.isPricingFree
69
? l10n.openrouter_pricing_free
70
: '${model.formattedInputPrice ?? '—'} / '
77
- '${model.formattedOutputPrice ?? '—'}',
71
+ '${model.formattedOutputPrice ?? '—'}',
72
),
73
],
74
),
@@ -100,12 +94,12 @@ class _ModelInfoRow extends StatelessWidget {
94
onPressed: () async {
95
await Clipboard.setData(ClipboardData(text: value));
96
if (context.mounted) {
103
- ScaffoldMessenger.of(context).showSnackBar(
104
- SnackBar(content: Text(l10n.copied_to_clipboard)),
105
- );
97
+ ScaffoldMessenger.of(
98
+ context,
99
+ ).showSnackBar(SnackBar(content: Text(l10n.copied_to_clipboard)));
100
}
101
},
102
),
103
);
104
}
111
-}
\ No newline at end of file
105
+}
lib/features/chat/views/components/top_bar/connection_banner.dart
+5
-3
@@ -23,8 +23,10 @@ class ConnectionBanner extends StatelessWidget {
23
: Colors.orange.withValues(alpha: 0.1),
24
child: Row(
25
children: [
26
- HugeIcon(icon:
27
- isError ? HugeIcons.strokeRoundedInformationCircle : HugeIcons.strokeRoundedWifiOff01,
26
+ HugeIcon(
27
+ icon: isError
28
+ ? HugeIcons.strokeRoundedInformationCircle
29
+ : HugeIcons.strokeRoundedWifiOff01,
30
size: 16,
31
color: isError ? Colors.red : Colors.orange,
32
),
@@ -52,4 +54,4 @@ class ConnectionBanner extends StatelessWidget {
54
),
55
);
56
}
55
-}
\ No newline at end of file
57
+}
lib/features/chat/views/components/top_bar/persona_indicator.dart
+10
-5
@@ -47,14 +47,16 @@ class PersonaIndicator extends StatelessWidget {
47
color: theme.colorScheme.primary.withValues(alpha: 0.1),
48
borderRadius: BorderRadius.circular(16),
49
border: Border.all(
50
- color:
51
- theme.colorScheme.primary.withValues(alpha: 0.2),
50
+ color: theme.colorScheme.primary.withValues(alpha: 0.2),
51
),
52
),
53
child: Row(
54
mainAxisSize: MainAxisSize.min,
55
children: [
57
- Text(persona.emoji, style: const TextStyle(fontSize: 16)),
56
+ Text(
57
+ persona.emoji,
58
+ style: const TextStyle(fontSize: 16),
59
+ ),
60
const SizedBox(width: 6),
61
Text(
62
persona.name,
@@ -73,7 +75,10 @@ class PersonaIndicator extends StatelessWidget {
75
),
76
const SizedBox(width: 8),
77
IconButton(
76
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedCancel01, size: 16),
78
+ icon: const HugeIcon(
79
+ icon: HugeIcons.strokeRoundedCancel01,
80
+ size: 16,
81
+ ),
82
onPressed: onClear,
83
padding: EdgeInsets.zero,
84
constraints: const BoxConstraints(),
@@ -84,4 +89,4 @@ class PersonaIndicator extends StatelessWidget {
89
),
90
);
91
}
87
-}
\ No newline at end of file
92
+}
lib/features/chat/views/components/typing_indicator.dart
+3
-2
@@ -46,7 +46,9 @@ class _TypingIndicatorState extends State<TypingIndicator>
46
final theme = Theme.of(context);
47
final isDark = theme.brightness == Brightness.dark;
48
49
- final dotColor = isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
49
+ final dotColor = isDark
50
+ ? AppColors.darkMutedText
51
+ : AppColors.lightMutedText;
52
53
return Padding(
54
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -79,4 +81,3 @@ class _TypingIndicatorState extends State<TypingIndicator>
81
);
82
}
83
}
82
-
lib/features/conversations/data/message_search_service.dart
+7
-2
@@ -33,7 +33,10 @@ class MessageSearchService {
33
final content = entity.content;
34
final index = content.toLowerCase().indexOf(normalizedQuery);
35
final start = index > 40 ? index - 40 : 0;
36
- final end = (index + normalizedQuery.length + 40).clamp(0, content.length);
36
+ final end = (index + normalizedQuery.length + 40).clamp(
37
+ 0,
38
+ content.length,
39
+ );
40
var snippet = content.substring(start, end);
41
if (start > 0) snippet = '…$snippet';
42
if (end < content.length) snippet = '$snippet…';
@@ -74,7 +77,9 @@ class MessageSearchService {
77
if (conversationIds.isEmpty) return const {};
78
79
final query = db.conversationBox
77
- .query(ConversationEntity_.id.oneOf(conversationIds.toList(growable: false)))
80
+ .query(
81
+ ConversationEntity_.id.oneOf(conversationIds.toList(growable: false)),
82
+ )
83
.build();
84
final entities = query.find();
85
query.close();
lib/features/conversations/data/models/conversation.dart
+9
-5
@@ -95,15 +95,19 @@ class Conversation {
95
modelId: modelId ?? this.modelId,
96
messageCount: messageCount ?? this.messageCount,
97
lastMessagePreview: lastMessagePreview ?? this.lastMessagePreview,
98
- systemPrompt:
99
- clearSystemPrompt ? null : (systemPrompt ?? this.systemPrompt),
98
+ systemPrompt: clearSystemPrompt
99
+ ? null
100
+ : (systemPrompt ?? this.systemPrompt),
101
temperature: clearTemperature ? null : (temperature ?? this.temperature),
102
topP: clearTopP ? null : (topP ?? this.topP),
103
maxTokens: clearMaxTokens ? null : (maxTokens ?? this.maxTokens),
103
- contextLength:
104
- clearContextLength ? null : (contextLength ?? this.contextLength),
104
+ contextLength: clearContextLength
105
+ ? null
106
+ : (contextLength ?? this.contextLength),
107
mcpEnabled: clearMcpEnabled ? null : (mcpEnabled ?? this.mcpEnabled),
106
- smartReplies: clearSmartReplies ? null : (smartReplies ?? this.smartReplies),
108
+ smartReplies: clearSmartReplies
109
+ ? null
110
+ : (smartReplies ?? this.smartReplies),
111
smartRepliesLastMessageId: clearSmartRepliesLastMessageId
112
? null
113
: (smartRepliesLastMessageId ?? this.smartRepliesLastMessageId),
lib/features/conversations/providers/conversation_providers.dart
+15
-9
@@ -313,7 +313,8 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
313
lastMessagePreview: preview,
314
updatedAt: updatedAt,
315
messageCount: messageCount ?? existing.messageCount,
316
- characterCount: characterCount ??
316
+ characterCount:
317
+ characterCount ??
318
(characterCountDelta != null
319
? existing.characterCount + characterCountDelta
320
: existing.characterCount),
@@ -578,7 +579,9 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
579
clearFolderId: folderId == null,
580
);
581
581
- final query = db.conversationBox.query(ConversationEntity_.id.equals(id)).build();
582
+ final query = db.conversationBox
583
+ .query(ConversationEntity_.id.equals(id))
584
+ .build();
585
final existingEntity = query.findFirst();
586
query.close();
587
@@ -711,9 +714,10 @@ final filteredConversationsProvider = Provider<AsyncValue<List<Conversation>>>((
714
715
filtered.sort((a, b) {
716
if (a.isPinned != b.isPinned) return a.isPinned ? -1 : 1;
714
- return historySortDate(b, sortOption).compareTo(
715
- historySortDate(a, sortOption),
716
- );
717
+ return historySortDate(
718
+ b,
719
+ sortOption,
720
+ ).compareTo(historySortDate(a, sortOption));
721
});
722
return filtered;
723
});
@@ -831,11 +835,13 @@ class HistorySelectedIdsNotifier extends Notifier<Set<String>> {
835
}
836
837
final conversationFoldersProvider =
834
- AsyncNotifierProvider<ConversationFoldersNotifier, List<ConversationFolder>>(
835
- () => ConversationFoldersNotifier(),
836
- );
838
+ AsyncNotifierProvider<
839
+ ConversationFoldersNotifier,
840
+ List<ConversationFolder>
841
+ >(() => ConversationFoldersNotifier());
842
838
-class ConversationFoldersNotifier extends AsyncNotifier<List<ConversationFolder>> {
843
+class ConversationFoldersNotifier
844
+ extends AsyncNotifier<List<ConversationFolder>> {
845
@override
846
Future<List<ConversationFolder>> build() async => _loadAll();
847
lib/features/conversations/views/chat_history_screen.dart
+43
-18
@@ -43,7 +43,9 @@ class ChatHistoryScreen extends ConsumerWidget {
43
bottom: 16,
44
),
45
decoration: BoxDecoration(
46
- color: isDark ? const Color(0xFF0A0A0A) : const Color(0xFFFAFAFA),
46
+ color: isDark
47
+ ? const Color(0xFF0A0A0A)
48
+ : const Color(0xFFFAFAFA),
49
border: Border(
50
bottom: BorderSide(
51
color: isDark
@@ -56,7 +58,9 @@ class ChatHistoryScreen extends ConsumerWidget {
58
? Row(
59
children: [
60
IconButton(
59
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedCancel01),
61
+ icon: const HugeIcon(
62
+ icon: HugeIcons.strokeRoundedCancel01,
63
+ ),
64
onPressed: () => ref
65
.read(historySelectionModeProvider.notifier)
66
.disable(),
@@ -72,30 +76,44 @@ class ChatHistoryScreen extends ConsumerWidget {
76
),
77
),
78
IconButton(
75
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedFolder01),
79
+ icon: const HugeIcon(
80
+ icon: HugeIcons.strokeRoundedFolder01,
81
+ ),
82
tooltip: l10n.move_to_folder,
83
onPressed: selectedIds.isEmpty
84
? null
85
: () => showBulkMoveToFolderSheet(
80
- context, ref, selectedIds),
86
+ context,
87
+ ref,
88
+ selectedIds,
89
+ ),
90
),
91
IconButton(
83
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedShare01),
92
+ icon: const HugeIcon(
93
+ icon: HugeIcons.strokeRoundedShare01,
94
+ ),
95
tooltip: l10n.export_conversation,
96
onPressed: selectedIds.isEmpty
97
? null
98
: () => runBulkExportConversations(
88
- context, ref, selectedIds),
99
+ context,
100
+ ref,
101
+ selectedIds,
102
+ ),
103
),
104
IconButton(
91
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedSparkles),
105
+ icon: const HugeIcon(
106
+ icon: HugeIcons.strokeRoundedSparkles,
107
+ ),
108
tooltip: l10n.ai_rename_tooltip,
109
onPressed: selectedIds.isEmpty
110
? null
111
: () async {
112
final ids = selectedIds.toList();
113
ref
98
- .read(historySelectionModeProvider.notifier)
114
+ .read(
115
+ historySelectionModeProvider.notifier,
116
+ )
117
.disable();
118
await runBulkAiRename(context, ref, ids);
119
},
@@ -106,7 +124,9 @@ class ChatHistoryScreen extends ConsumerWidget {
124
children: [
125
Builder(
126
builder: (context) => IconButton(
109
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedMenu01),
127
+ icon: const HugeIcon(
128
+ icon: HugeIcons.strokeRoundedMenu01,
129
+ ),
130
onPressed: () => Scaffold.of(context).openDrawer(),
131
),
132
),
@@ -121,7 +141,9 @@ class ChatHistoryScreen extends ConsumerWidget {
141
),
142
const Spacer(),
143
IconButton(
124
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedCheckList),
144
+ icon: const HugeIcon(
145
+ icon: HugeIcons.strokeRoundedCheckList,
146
+ ),
147
tooltip: l10n.select,
148
onPressed: () => ref
149
.read(historySelectionModeProvider.notifier)
@@ -181,8 +203,10 @@ class ChatHistoryScreen extends ConsumerWidget {
203
if (messageSearchHits.isNotEmpty)
204
ConstrainedBox(
205
constraints: BoxConstraints(
184
- maxHeight: (MediaQuery.sizeOf(context).height * 0.35)
185
- .clamp(120.0, 280.0),
206
+ maxHeight: (MediaQuery.sizeOf(context).height * 0.35).clamp(
207
+ 120.0,
208
+ 280.0,
209
+ ),
210
),
211
child: const SingleChildScrollView(
212
child: MessageSearchResultsList(),
@@ -192,7 +216,9 @@ class ChatHistoryScreen extends ConsumerWidget {
216
Expanded(
217
child: groupedConversations.when(
218
data: (grouped) => grouped.isEmpty
195
- ? ConversationEmptyState(isSearching: searchQuery.isNotEmpty)
219
+ ? ConversationEmptyState(
220
+ isSearching: searchQuery.isNotEmpty,
221
+ )
222
: ConversationList(
223
groupedConversations: grouped,
224
activeConversation: activeConversation,
@@ -217,12 +243,11 @@ class ChatHistoryScreen extends ConsumerWidget {
243
child: FloatingActionButton(
244
tooltip: l10n.new_chat_in_folder_tooltip,
245
onPressed: () {
220
- final folderId = (currentFolder != null && currentFolder.isNotEmpty)
246
+ final folderId =
247
+ (currentFolder != null && currentFolder.isNotEmpty)
248
? currentFolder
249
: null;
223
- ref
224
- .read(pendingNewChatFolderIdProvider.notifier)
225
- .set(folderId);
250
+ ref.read(pendingNewChatFolderIdProvider.notifier).set(folderId);
251
ref.read(chatProvider.notifier).startNewConversation();
252
context.go(AppRoutes.home);
253
},
@@ -232,4 +257,4 @@ class ChatHistoryScreen extends ConsumerWidget {
257
],
258
);
259
}
235
-}
\ No newline at end of file
260
+}
lib/features/conversations/views/components/conversation_empty_state.dart
+2
-5
@@ -4,10 +4,7 @@ import 'package:flutter/material.dart';
4
import 'package:localmind/l10n/app_localizations.dart';
5
6
class ConversationEmptyState extends StatelessWidget {
7
- const ConversationEmptyState({
8
- super.key,
9
- required this.isSearching,
10
- });
7
+ const ConversationEmptyState({super.key, required this.isSearching});
8
9
final bool isSearching;
10
@@ -74,4 +71,4 @@ class ConversationEmptyState extends StatelessWidget {
71
),
72
);
73
}
77
-}
\ No newline at end of file
74
+}
lib/features/conversations/views/components/conversation_list.dart
+22
-16
@@ -42,8 +42,9 @@ class ConversationList extends ConsumerWidget {
42
..sort((a, b) {
43
final aIndex = sectionOrder.indexOf(a);
44
final bIndex = sectionOrder.indexOf(b);
45
- return (aIndex == -1 ? 999 : aIndex)
46
- .compareTo(bIndex == -1 ? 999 : bIndex);
45
+ return (aIndex == -1 ? 999 : aIndex).compareTo(
46
+ bIndex == -1 ? 999 : bIndex,
47
+ );
48
});
49
50
return ListView.builder(
@@ -122,10 +123,9 @@ class ConversationList extends ConsumerWidget {
123
_exportConversation(context, ref, l10n, conversation);
124
},
125
onArchive: () {
125
- ref.read(conversationsProvider.notifier).setArchived(
126
- conversation.id,
127
- !conversation.isArchived,
128
- );
126
+ ref
127
+ .read(conversationsProvider.notifier)
128
+ .setArchived(conversation.id, !conversation.isArchived);
129
},
130
),
131
);
@@ -185,8 +185,10 @@ class ConversationList extends ConsumerWidget {
185
Conversation conversation,
186
) async {
187
final db = ref.read(databaseProvider);
188
- final json = DataBackupService()
189
- .exportConversationAsJson(db.store, conversation.id);
188
+ final json = DataBackupService().exportConversationAsJson(
189
+ db.store,
190
+ conversation.id,
191
+ );
192
if (!context.mounted) return;
193
await showExportChoiceDialog(
194
context,
@@ -297,8 +299,10 @@ Future<void> runBulkExportConversations(
299
) async {
300
final l10n = AppLocalizations.of(context)!;
301
final db = ref.read(databaseProvider);
300
- final json = DataBackupService()
301
- .exportConversationsForIdsAsJson(db.store, conversationIds);
302
+ final json = DataBackupService().exportConversationsForIdsAsJson(
303
+ db.store,
304
+ conversationIds,
305
+ );
306
if (!context.mounted) return;
307
await showExportChoiceDialog(
308
context,
@@ -318,9 +322,7 @@ Future<void> runBulkAiRename(
322
context: context,
323
builder: (ctx) => AlertDialog(
324
title: Text(l10n.bulk_ai_rename_confirm_title),
321
- content: Text(
322
- l10n.bulk_ai_rename_confirm_body(conversationIds.length),
323
- ),
325
+ content: Text(l10n.bulk_ai_rename_confirm_body(conversationIds.length)),
326
actions: [
327
TextButton(
328
onPressed: () => Navigator.pop(ctx, false),
@@ -365,9 +367,13 @@ Future<void> runBulkAiRename(
367
368
for (final id in conversationIds) {
369
try {
368
- final title = await ref.read(chatProvider.notifier).generateTitleWithAi(id);
370
+ final title = await ref
371
+ .read(chatProvider.notifier)
372
+ .generateTitleWithAi(id);
373
if (title != null && title.isNotEmpty) {
370
- await ref.read(conversationsProvider.notifier).renameConversation(id, title);
374
+ await ref
375
+ .read(conversationsProvider.notifier)
376
+ .renameConversation(id, title);
377
}
378
} catch (_) {
379
// continue renaming remaining conversations
@@ -376,4 +382,4 @@ Future<void> runBulkAiRename(
382
}
383
384
if (context.mounted) Navigator.of(context, rootNavigator: true).pop();
379
-}
\ No newline at end of file
385
+}
lib/features/conversations/views/components/conversation_search_bar.dart
+13
-12
@@ -80,8 +80,8 @@ class _ConversationSearchBarState extends ConsumerState<ConversationSearchBar> {
80
prefixIcon: Center(
81
widthFactor: 1.0,
82
heightFactor: 1.0,
83
- child: HugeIcon(icon:
84
- HugeIcons.strokeRoundedSearch01,
83
+ child: HugeIcon(
84
+ icon: HugeIcons.strokeRoundedSearch01,
85
size: 20,
86
color: isDark ? const Color(0xFF666666) : const Color(0xFF999999),
87
),
@@ -90,24 +90,25 @@ class _ConversationSearchBarState extends ConsumerState<ConversationSearchBar> {
90
mainAxisSize: MainAxisSize.min,
91
children: [
92
IconButton(
93
- icon: HugeIcon(icon:
94
- searchContents ? HugeIcons.strokeRoundedDocumentCode : HugeIcons.strokeRoundedDocumentCode,
93
+ icon: HugeIcon(
94
+ icon: searchContents
95
+ ? HugeIcons.strokeRoundedDocumentCode
96
+ : HugeIcons.strokeRoundedDocumentCode,
97
size: 20,
98
color: searchContents
99
? theme.colorScheme.primary
100
: (isDark
99
- ? const Color(0xFF666666)
100
- : const Color(0xFF999999)),
101
+ ? const Color(0xFF666666)
102
+ : const Color(0xFF999999)),
103
),
104
tooltip: l10n.search_message_contents,
103
- onPressed: () => ref
104
- .read(searchMessageContentsProvider.notifier)
105
- .toggle(),
105
+ onPressed: () =>
106
+ ref.read(searchMessageContentsProvider.notifier).toggle(),
107
),
108
if (_controller.text.isNotEmpty)
109
IconButton(
109
- icon: HugeIcon(icon:
110
- HugeIcons.strokeRoundedCancel01,
110
+ icon: HugeIcon(
111
+ icon: HugeIcons.strokeRoundedCancel01,
112
size: 18,
113
color: isDark
114
? const Color(0xFF666666)
@@ -130,4 +131,4 @@ class _ConversationSearchBarState extends ConsumerState<ConversationSearchBar> {
131
),
132
);
133
}
133
-}
\ No newline at end of file
134
+}
lib/features/conversations/views/components/conversation_tile.dart
+44
-18
@@ -51,7 +51,11 @@ class ConversationTile extends StatelessWidget {
51
} else if (diff.inDays < 7) {
52
return l10n.conversation_days_ago(diff.inDays);
53
} else {
54
- return l10n.conversation_date(dateTime.month, dateTime.day, dateTime.year);
54
+ return l10n.conversation_date(
55
+ dateTime.month,
56
+ dateTime.day,
57
+ dateTime.year,
58
+ );
59
}
60
}
61
@@ -68,8 +72,8 @@ class ConversationTile extends StatelessWidget {
72
alignment: AlignmentDirectional.centerStart,
73
padding: const EdgeInsetsDirectional.only(start: 16),
74
color: Colors.blue,
71
- child: HugeIcon(icon:
72
- conversation.isArchived
75
+ child: HugeIcon(
76
+ icon: conversation.isArchived
77
? HugeIcons.strokeRoundedArchive
78
: HugeIcons.strokeRoundedArchive,
79
color: Colors.white,
@@ -79,7 +83,10 @@ class ConversationTile extends StatelessWidget {
83
alignment: AlignmentDirectional.centerEnd,
84
padding: const EdgeInsetsDirectional.only(end: 16),
85
color: Colors.red,
82
- child: const HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: Colors.white),
86
+ child: const HugeIcon(
87
+ icon: HugeIcons.strokeRoundedDelete01,
88
+ color: Colors.white,
89
+ ),
90
),
91
confirmDismiss: (direction) async {
92
if (direction == DismissDirection.startToEnd) {
@@ -105,8 +112,8 @@ class ConversationTile extends StatelessWidget {
112
if (selectionMode)
113
Checkbox(value: isSelected, onChanged: (_) => onTap())
114
else
108
- HugeIcon(icon:
109
- conversation.isPinned
115
+ HugeIcon(
116
+ icon: conversation.isPinned
117
? HugeIcons.strokeRoundedPin
118
: HugeIcons.strokeRoundedChatting01,
119
size: 20,
@@ -130,7 +137,9 @@ class ConversationTile extends StatelessWidget {
137
fontWeight: isActive
138
? FontWeight.w600
139
: FontWeight.w500,
133
- color: isDark ? AppColors.darkPrimaryText : AppColors.lightPrimaryText,
140
+ color: isDark
141
+ ? AppColors.darkPrimaryText
142
+ : AppColors.lightPrimaryText,
143
),
144
maxLines: 1,
145
overflow: TextOverflow.ellipsis,
@@ -152,10 +161,16 @@ class ConversationTile extends StatelessWidget {
161
const SizedBox(height: 2),
162
Text(
163
[
155
- l10n.conversation_message_count(conversation.messageCount),
156
- l10n.conversation_character_count(conversation.characterCount),
164
+ l10n.conversation_message_count(
165
+ conversation.messageCount,
166
+ ),
167
+ l10n.conversation_character_count(
168
+ conversation.characterCount,
169
+ ),
170
if (conversation.totalTokenCount != null)
158
- l10n.total_tokens_count(conversation.totalTokenCount!),
171
+ l10n.total_tokens_count(
172
+ conversation.totalTokenCount!,
173
+ ),
174
].join(' · '),
175
style: TextStyle(
176
fontSize: 11,
@@ -200,7 +215,11 @@ class ConversationTile extends StatelessWidget {
215
);
216
}
217
203
- void _showContextMenu(BuildContext context, AppLocalizations l10n, bool isDark) {
218
+ void _showContextMenu(
219
+ BuildContext context,
220
+ AppLocalizations l10n,
221
+ bool isDark,
222
+ ) {
223
showModalBottomSheet(
224
context: context,
225
builder: (ctx) {
@@ -210,7 +229,9 @@ class ConversationTile extends StatelessWidget {
229
children: [
230
if (onEnterSelectionMode != null)
231
ListTile(
213
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedCheckList),
232
+ leading: const HugeIcon(
233
+ icon: HugeIcons.strokeRoundedCheckList,
234
+ ),
235
title: Text(l10n.select),
236
onTap: () {
237
Navigator.pop(ctx);
@@ -218,8 +239,8 @@ class ConversationTile extends StatelessWidget {
239
},
240
),
241
ListTile(
221
- leading: HugeIcon(icon:
222
- conversation.isPinned
242
+ leading: HugeIcon(
243
+ icon: conversation.isPinned
244
? HugeIcons.strokeRoundedPin
245
: HugeIcons.strokeRoundedPin,
246
),
@@ -230,7 +251,9 @@ class ConversationTile extends StatelessWidget {
251
},
252
),
253
ListTile(
233
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedPencilEdit02),
254
+ leading: const HugeIcon(
255
+ icon: HugeIcons.strokeRoundedPencilEdit02,
256
+ ),
257
title: Text(l10n.rename),
258
onTap: () {
259
Navigator.pop(ctx);
@@ -262,8 +285,8 @@ class ConversationTile extends StatelessWidget {
285
},
286
),
287
ListTile(
265
- leading: HugeIcon(icon:
266
- conversation.isArchived
288
+ leading: HugeIcon(
289
+ icon: conversation.isArchived
290
? HugeIcons.strokeRoundedArchive
291
: HugeIcons.strokeRoundedArchive,
292
),
@@ -278,7 +301,10 @@ class ConversationTile extends StatelessWidget {
301
},
302
),
303
ListTile(
281
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: Colors.red),
304
+ leading: const HugeIcon(
305
+ icon: HugeIcons.strokeRoundedDelete01,
306
+ color: Colors.red,
307
+ ),
308
title: Text(
309
l10n.delete,
310
style: const TextStyle(color: Colors.red),
lib/features/conversations/views/components/message_search_results_list.dart
+3
-3
@@ -38,8 +38,8 @@ class MessageSearchResultsList extends ConsumerWidget {
38
itemBuilder: (context, index) {
39
final hit = hits[index];
40
return ListTile(
41
- leading: HugeIcon(icon:
42
- hit.role == MessageRole.user
41
+ leading: HugeIcon(
42
+ icon: hit.role == MessageRole.user
43
? HugeIcons.strokeRoundedUser
44
: HugeIcons.strokeRoundedRobot01,
45
),
@@ -85,4 +85,4 @@ class MessageSearchResultsList extends ConsumerWidget {
85
],
86
);
87
}
88
-}
\ No newline at end of file
88
+}
lib/features/conversations/views/components/new_chat_button.dart
+1
-1
@@ -31,4 +31,4 @@ class NewChatButton extends ConsumerWidget {
31
),
32
);
33
}
34
-}
\ No newline at end of file
34
+}
lib/features/conversations/views/components/rename_conversation_dialog.dart
+5
-2
@@ -66,7 +66,10 @@ Future<void> showRenameConversationDialog(
66
height: 16,
67
child: CircularProgressIndicator(strokeWidth: 2),
68
)
69
- : const HugeIcon(icon: HugeIcons.strokeRoundedSparkles, size: 18),
69
+ : const HugeIcon(
70
+ icon: HugeIcons.strokeRoundedSparkles,
71
+ size: 18,
72
+ ),
73
label: Text(
74
isGenerating
75
? l10n.generating_title
@@ -104,4 +107,4 @@ Future<void> showRenameConversationDialog(
107
);
108
109
controller.dispose();
107
-}
\ No newline at end of file
110
+}
lib/features/lm_studio_catalog/data/catalog_models.dart
+14
-16
@@ -29,6 +29,7 @@ class LmCatalogModel {
29
final LmCatalogMetadata metadata;
30
final String? url;
31
final LmCatalogSource source;
32
+
33
/// Hugging Face repo used for quant listing / HF downloads.
34
final String? hfRepoId;
35
@@ -46,7 +47,8 @@ class LmCatalogModel {
47
'https://lmstudio.ai/api/v1/artifacts/$owner/$name/revision/$revisionNumber?action=readme';
48
49
String? get hfDownloadBaseUrl {
49
- final repo = hfRepoId ?? (source == LmCatalogSource.huggingFace ? id : null);
50
+ final repo =
51
+ hfRepoId ?? (source == LmCatalogSource.huggingFace ? id : null);
52
if (repo == null || repo.isEmpty) return null;
53
return 'https://huggingface.co/$repo';
54
}
@@ -81,7 +83,8 @@ class LmCatalogModel {
83
final parts = modelId.split('/');
84
final owner = parts.isNotEmpty ? parts.first : '';
85
final name = parts.length > 1 ? parts.sublist(1).join('/') : modelId;
84
- final tags = (json['tags'] as List<dynamic>?)
86
+ final tags =
87
+ (json['tags'] as List<dynamic>?)
88
?.map((e) => e.toString().toLowerCase())
89
.toList() ??
90
const [];
@@ -97,8 +100,8 @@ class LmCatalogModel {
100
updatedAt: json['lastModified'] != null
101
? DateTime.tryParse(json['lastModified'].toString())
102
: (json['createdAt'] != null
100
- ? DateTime.tryParse(json['createdAt'].toString())
101
- : null),
103
+ ? DateTime.tryParse(json['createdAt'].toString())
104
+ : null),
105
isStaffPick: false,
106
isVerified: false,
107
metadata: LmCatalogMetadata(
@@ -181,7 +184,8 @@ class LmCatalogMetadata {
184
trainedForToolUse: json['trainedForToolUse'] == true,
185
vision: json['vision'] == true,
186
reasoning: json['reasoning'] == true,
184
- contextLengths: (json['contextLengths'] as List<dynamic>?)
187
+ contextLengths:
188
+ (json['contextLengths'] as List<dynamic>?)
189
?.map((e) => (e as num).toInt())
190
.toList() ??
191
const [],
@@ -190,11 +194,7 @@ class LmCatalogMetadata {
194
}
195
196
class LmArtifactManifest {
193
- const LmArtifactManifest({
194
- this.hfRepoId,
195
- this.downloadCount,
196
- this.readme,
197
- });
197
+ const LmArtifactManifest({this.hfRepoId, this.downloadCount, this.readme});
198
199
final String? hfRepoId;
200
final int? downloadCount;
@@ -276,8 +276,9 @@ class LmModelQuantOption {
276
static LmModelQuantOption? recommended(List<LmModelQuantOption> quants) {
277
if (quants.isEmpty) return null;
278
for (final preferred in preferredQuantOrder) {
279
- final match =
280
- quants.where((q) => q.quantization == preferred).firstOrNull;
279
+ final match = quants
280
+ .where((q) => q.quantization == preferred)
281
+ .firstOrNull;
282
if (match != null) return match;
283
}
284
return quants.first;
@@ -462,10 +463,7 @@ enum LmDownloadStatus {
463
}
464
465
class HfSearchPage {
465
- const HfSearchPage({
466
- required this.models,
467
- this.nextUrl,
468
- });
466
+ const HfSearchPage({required this.models, this.nextUrl});
467
468
final List<LmCatalogModel> models;
469
final String? nextUrl;
lib/features/lm_studio_catalog/data/lm_studio_catalog_service.dart
+3
-2
@@ -203,8 +203,9 @@ class LmStudioCatalogService {
203
final trimmed = query.trim().toLowerCase();
204
if (trimmed.isEmpty) return staffPicks;
205
206
- final staffMatches =
207
- staffPicks.where((model) => model.matchesQuery(trimmed)).toList();
206
+ final staffMatches = staffPicks
207
+ .where((model) => model.matchesQuery(trimmed))
208
+ .toList();
209
210
List<LmCatalogModel> community = [];
211
try {
lib/features/lm_studio_catalog/data/lm_studio_download_service.dart
+4
-5
@@ -31,10 +31,7 @@ class LmStudioDownloadService {
31
}
32
33
if (hfUrl != null) {
34
- return LmDownloadRequest(
35
- model: hfUrl,
36
- displayName: model.displayLabel,
37
- );
34
+ return LmDownloadRequest(model: hfUrl, displayName: model.displayLabel);
35
}
36
37
if (model.source == LmCatalogSource.lmStudio) {
@@ -76,7 +73,9 @@ class LmStudioDownloadService {
73
final message = error is Map
74
? error['message']?.toString()
75
: error?.toString();
79
- throw Exception(message ?? 'Download failed (status ${response.statusCode})');
76
+ throw Exception(
77
+ message ?? 'Download failed (status ${response.statusCode})',
78
+ );
79
}
80
81
final data = response.data ?? <String, dynamic>{};
lib/features/lm_studio_catalog/views/lm_studio_download_widgets.dart
+14
-7
@@ -45,8 +45,9 @@ class LmDownloadIndicatorButton extends ConsumerWidget {
45
value: activeCount > 0 ? progress : null,
46
strokeWidth: compact ? 2 : 2.5,
47
color: theme.colorScheme.primary,
48
- backgroundColor:
49
- isDark ? AppColors.darkBorder : AppColors.lightBorder,
48
+ backgroundColor: isDark
49
+ ? AppColors.darkBorder
50
+ : AppColors.lightBorder,
51
),
52
),
53
Text(
@@ -189,7 +190,9 @@ class _DownloadJobTile extends ConsumerWidget {
190
job.displayName,
191
maxLines: 2,
192
overflow: TextOverflow.ellipsis,
192
- style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
193
+ style: theme.textTheme.bodyMedium?.copyWith(
194
+ fontWeight: FontWeight.w600,
195
+ ),
196
),
197
subtitle: Column(
198
crossAxisAlignment: CrossAxisAlignment.start,
@@ -211,8 +214,9 @@ class _DownloadJobTile extends ConsumerWidget {
214
value: job.progressFraction,
215
minHeight: 6,
216
color: theme.colorScheme.primary,
214
- backgroundColor:
215
- isDark ? AppColors.darkBorder : AppColors.lightBorder,
217
+ backgroundColor: isDark
218
+ ? AppColors.darkBorder
219
+ : AppColors.lightBorder,
220
),
221
),
222
const SizedBox(height: 4),
@@ -230,11 +234,14 @@ class _DownloadJobTile extends ConsumerWidget {
234
trailing: job.status.isActive
235
? null
236
: IconButton(
233
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedCancel01, size: 18),
237
+ icon: const HugeIcon(
238
+ icon: HugeIcons.strokeRoundedCancel01,
239
+ size: 18,
240
+ ),
241
onPressed: () => ref
242
.read(lmDownloadManagerProvider.notifier)
243
.removeJob(job.jobId),
244
),
245
);
246
}
240
-}
\ No newline at end of file
247
+}
lib/features/lm_studio_catalog/views/lm_studio_model_browser_screen.dart
+189
-134
@@ -42,14 +42,14 @@ class _LmStudioModelBrowserScreenState
42
vsync: this,
43
duration: const Duration(milliseconds: 280),
44
);
45
- _panelSlide = Tween<Offset>(
46
- begin: const Offset(1, 0),
47
- end: Offset.zero,
48
- ).animate(CurvedAnimation(
49
- parent: _panelController,
50
- curve: Curves.easeOutCubic,
51
- reverseCurve: Curves.easeInCubic,
52
- ));
45
+ _panelSlide = Tween<Offset>(begin: const Offset(1, 0), end: Offset.zero)
46
+ .animate(
47
+ CurvedAnimation(
48
+ parent: _panelController,
49
+ curve: Curves.easeOutCubic,
50
+ reverseCurve: Curves.easeInCubic,
51
+ ),
52
+ );
53
_listScrollController.addListener(_onListScroll);
54
}
55
@@ -100,9 +100,7 @@ class _LmStudioModelBrowserScreenState
100
final isSearching = trimmedQuery.isNotEmpty;
101
102
final staffPicksAsync = ref.watch(lmStudioStaffPicksProvider);
103
- final searchState = isSearching
104
- ? ref.watch(lmCatalogSearchProvider)
105
- : null;
103
+ final searchState = isSearching ? ref.watch(lmCatalogSearchProvider) : null;
104
105
return PopScope(
106
canPop: _selectedModel == null,
@@ -111,95 +109,102 @@ class _LmStudioModelBrowserScreenState
109
_closeDetails();
110
},
111
child: Scaffold(
114
- backgroundColor:
115
- isDark ? AppColors.darkBackground : AppColors.lightBackground,
116
- appBar: AppBar(
117
- title: Text(l10n.lm_studio_model_browser_title),
118
- actions: const [
119
- LmDownloadIndicatorButton(),
120
- ],
121
- ),
122
- body: Stack(
123
- children: [
124
- Column(
125
- children: [
126
- Padding(
127
- padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
128
- child: TextField(
129
- controller: _searchController,
130
- onChanged: _onSearchChanged,
131
- decoration: InputDecoration(
132
- hintText: l10n.lm_studio_model_search_hint,
133
- prefixIcon: const Center(
134
- widthFactor: 1.0,
135
- heightFactor: 1.0,
136
- child: HugeIcon(icon: HugeIcons.strokeRoundedSearch01, size: 20),
137
- ),
138
- suffixIcon: _searchQuery.isNotEmpty
139
- ? IconButton(
140
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedCancel01, size: 18),
141
- onPressed: () {
142
- _searchController.clear();
143
- _onSearchChanged('');
144
- },
145
- )
146
- : null,
147
- filled: true,
148
- fillColor:
149
- isDark ? AppColors.darkSurface : AppColors.lightSurface,
150
- border: OutlineInputBorder(
151
- borderRadius: BorderRadius.circular(12),
152
- borderSide: BorderSide.none,
112
+ backgroundColor: isDark
113
+ ? AppColors.darkBackground
114
+ : AppColors.lightBackground,
115
+ appBar: AppBar(
116
+ title: Text(l10n.lm_studio_model_browser_title),
117
+ actions: const [LmDownloadIndicatorButton()],
118
+ ),
119
+ body: Stack(
120
+ children: [
121
+ Column(
122
+ children: [
123
+ Padding(
124
+ padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
125
+ child: TextField(
126
+ controller: _searchController,
127
+ onChanged: _onSearchChanged,
128
+ decoration: InputDecoration(
129
+ hintText: l10n.lm_studio_model_search_hint,
130
+ prefixIcon: const Center(
131
+ widthFactor: 1.0,
132
+ heightFactor: 1.0,
133
+ child: HugeIcon(
134
+ icon: HugeIcons.strokeRoundedSearch01,
135
+ size: 20,
136
+ ),
137
+ ),
138
+ suffixIcon: _searchQuery.isNotEmpty
139
+ ? IconButton(
140
+ icon: const HugeIcon(
141
+ icon: HugeIcons.strokeRoundedCancel01,
142
+ size: 18,
143
+ ),
144
+ onPressed: () {
145
+ _searchController.clear();
146
+ _onSearchChanged('');
147
+ },
148
+ )
149
+ : null,
150
+ filled: true,
151
+ fillColor: isDark
152
+ ? AppColors.darkSurface
153
+ : AppColors.lightSurface,
154
+ border: OutlineInputBorder(
155
+ borderRadius: BorderRadius.circular(12),
156
+ borderSide: BorderSide.none,
157
+ ),
158
),
159
),
160
),
156
- ),
157
- Expanded(
158
- child: isSearching
159
- ? _buildSearchList(searchState, l10n, theme)
160
- : staffPicksAsync.when(
161
- loading: () =>
162
- const Center(child: CircularProgressIndicator()),
163
- error: (error, _) => Center(
164
- child:
165
- Text(l10n.error_with_message(error.toString())),
161
+ Expanded(
162
+ child: isSearching
163
+ ? _buildSearchList(searchState, l10n, theme)
164
+ : staffPicksAsync.when(
165
+ loading: () =>
166
+ const Center(child: CircularProgressIndicator()),
167
+ error: (error, _) => Center(
168
+ child: Text(
169
+ l10n.error_with_message(error.toString()),
170
+ ),
171
+ ),
172
+ data: (models) =>
173
+ _buildModelList(models, l10n, theme, null),
174
),
167
- data: (models) =>
168
- _buildModelList(models, l10n, theme, null),
169
- ),
170
- ),
171
- ],
172
- ),
173
- if (_selectedModel != null) ...[
174
- GestureDetector(
175
- onTap: _closeDetails,
176
- child: AnimatedBuilder(
177
- animation: _panelController,
178
- builder: (context, _) => Container(
179
- color: Colors.black.withValues(
180
- alpha: 0.45 * _panelController.value,
175
+ ),
176
+ ],
177
+ ),
178
+ if (_selectedModel != null) ...[
179
+ GestureDetector(
180
+ onTap: _closeDetails,
181
+ child: AnimatedBuilder(
182
+ animation: _panelController,
183
+ builder: (context, _) => Container(
184
+ color: Colors.black.withValues(
185
+ alpha: 0.45 * _panelController.value,
186
+ ),
187
),
188
),
189
),
184
- ),
185
- SlideTransition(
186
- position: _panelSlide,
187
- child: Align(
188
- alignment: Alignment.centerRight,
189
- child: SizedBox(
190
- width: MediaQuery.sizeOf(context).width * 0.85,
191
- child: _ModelDetailPanel(
192
- server: widget.server,
193
- model: _selectedModel!,
194
- onClose: _closeDetails,
195
- onSwipeDismiss: _closeDetails,
190
+ SlideTransition(
191
+ position: _panelSlide,
192
+ child: Align(
193
+ alignment: Alignment.centerRight,
194
+ child: SizedBox(
195
+ width: MediaQuery.sizeOf(context).width * 0.85,
196
+ child: _ModelDetailPanel(
197
+ server: widget.server,
198
+ model: _selectedModel!,
199
+ onClose: _closeDetails,
200
+ onSwipeDismiss: _closeDetails,
201
+ ),
202
),
203
),
204
),
199
- ),
205
+ ],
206
],
201
- ],
202
- ),
207
+ ),
208
),
209
);
210
}
@@ -238,7 +243,9 @@ class _LmStudioModelBrowserScreenState
243
}) {
244
final isSearching = _searchQuery.trim().isNotEmpty;
245
final staff = isSearching ? (staffMatches ?? []) : models;
241
- final community = isSearching ? (communityModels ?? []) : const <LmCatalogModel>[];
246
+ final community = isSearching
247
+ ? (communityModels ?? [])
248
+ : const <LmCatalogModel>[];
249
250
return ListView(
251
controller: _listScrollController,
@@ -326,8 +333,9 @@ class _ModelListTile extends StatelessWidget {
333
334
return Material(
335
color: selected
329
- ? (isDark ? AppColors.darkAccent : AppColors.lightAccent)
330
- .withValues(alpha: 0.18)
336
+ ? (isDark ? AppColors.darkAccent : AppColors.lightAccent).withValues(
337
+ alpha: 0.18,
338
+ )
339
: Colors.transparent,
340
borderRadius: BorderRadius.circular(12),
341
child: InkWell(
@@ -346,8 +354,7 @@ class _ModelListTile extends StatelessWidget {
354
width: 40,
355
height: 40,
356
fit: BoxFit.cover,
349
- errorBuilder: (_, _, _) =>
350
- _FallbackIcon(model: model),
357
+ errorBuilder: (_, _, _) => _FallbackIcon(model: model),
358
)
359
: _FallbackIcon(model: model),
360
),
@@ -369,8 +376,8 @@ class _ModelListTile extends StatelessWidget {
376
),
377
),
378
if (model.isVerified)
372
- HugeIcon(icon:
373
- HugeIcons.strokeRoundedCheckmarkBadge01,
379
+ HugeIcon(
380
+ icon: HugeIcons.strokeRoundedCheckmarkBadge01,
381
size: 16,
382
color: isDark
383
? AppColors.darkAccent
@@ -378,8 +385,8 @@ class _ModelListTile extends StatelessWidget {
385
),
386
if (model.isStaffPick) ...[
387
const SizedBox(width: 4),
381
- HugeIcon(icon:
382
- HugeIcons.strokeRoundedSparkles,
388
+ HugeIcon(
389
+ icon: HugeIcons.strokeRoundedSparkles,
390
size: 14,
391
color: Colors.purple.shade300,
392
),
@@ -410,8 +417,11 @@ class _ModelListTile extends StatelessWidget {
417
_CapabilityIcons(model: model),
418
const Spacer(),
419
if (model.likes > 0) ...[
413
- HugeIcon(icon: HugeIcons.strokeRoundedFavourite,
414
- size: 14, color: theme.hintColor),
420
+ HugeIcon(
421
+ icon: HugeIcons.strokeRoundedFavourite,
422
+ size: 14,
423
+ color: theme.hintColor,
424
+ ),
425
const SizedBox(width: 2),
426
Text(
427
_formatCount(model.likes),
@@ -420,8 +430,11 @@ class _ModelListTile extends StatelessWidget {
430
const SizedBox(width: 8),
431
],
432
if (model.downloads > 0) ...[
423
- HugeIcon(icon: HugeIcons.strokeRoundedDownload01,
424
- size: 14, color: theme.hintColor),
433
+ HugeIcon(
434
+ icon: HugeIcons.strokeRoundedDownload01,
435
+ size: 14,
436
+ color: theme.hintColor,
437
+ ),
438
const SizedBox(width: 2),
439
Text(
440
_formatCount(model.downloads),
@@ -560,14 +573,16 @@ class _ModelDetailPanelState extends ConsumerState<_ModelDetailPanel> {
573
final l10n = AppLocalizations.of(context)!;
574
final quant = _effectiveQuant(detail.quants);
575
if (detail.quants.isNotEmpty && quant == null) {
563
- ScaffoldMessenger.of(context).showSnackBar(
564
- SnackBar(content: Text(l10n.lm_studio_download_options)),
565
- );
576
+ ScaffoldMessenger.of(
577
+ context,
578
+ ).showSnackBar(SnackBar(content: Text(l10n.lm_studio_download_options)));
579
return;
580
}
581
582
try {
570
- await ref.read(lmDownloadManagerProvider.notifier).startDownload(
583
+ await ref
584
+ .read(lmDownloadManagerProvider.notifier)
585
+ .startDownload(
586
server: widget.server,
587
model: widget.model,
588
detail: detail,
@@ -612,20 +627,23 @@ class _ModelDetailPanelState extends ConsumerState<_ModelDetailPanel> {
627
data: (detail) {
628
final selectedQuant = _effectiveQuant(detail.quants);
629
final activeJob = _activeJobFor(detail);
615
- final selectedSize = selectedQuant?.sizeBytes ??
630
+ final selectedSize =
631
+ selectedQuant?.sizeBytes ??
632
detail.model.metadata.minMemoryUsageBytes;
633
final compatibility = estimateMemoryCompatibility(
634
modelSizeBytes: selectedSize,
635
availableRamGb: widget.server.availableRamGb,
636
availableVramGb: widget.server.availableVramGb,
637
);
622
- final serverModels =
623
- ref.watch(availableModelsProvider(widget.server.id));
638
+ final serverModels = ref.watch(
639
+ availableModelsProvider(widget.server.id),
640
+ );
641
final downloadedModels = serverModels.maybeWhen(
642
data: downloadedModelsList,
643
orElse: () => const <ModelInfo>[],
644
);
628
- final selectedDownloaded = selectedQuant != null &&
645
+ final selectedDownloaded =
646
+ selectedQuant != null &&
647
isQuantDownloaded(
648
model: widget.model,
649
quant: selectedQuant,
@@ -640,7 +658,9 @@ class _ModelDetailPanelState extends ConsumerState<_ModelDetailPanel> {
658
child: Row(
659
children: [
660
IconButton(
643
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedArrowLeft01),
661
+ icon: const HugeIcon(
662
+ icon: HugeIcons.strokeRoundedArrowLeft01,
663
+ ),
664
onPressed: widget.onClose,
665
),
666
Expanded(
@@ -654,7 +674,9 @@ class _ModelDetailPanelState extends ConsumerState<_ModelDetailPanel> {
674
),
675
),
676
IconButton(
657
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedCancel01),
677
+ icon: const HugeIcon(
678
+ icon: HugeIcons.strokeRoundedCancel01,
679
+ ),
680
onPressed: widget.onClose,
681
),
682
],
@@ -691,15 +713,21 @@ class _ModelDetailPanelState extends ConsumerState<_ModelDetailPanel> {
713
Row(
714
children: [
715
if (widget.model.likes > 0) ...[
694
- HugeIcon(icon: HugeIcons.strokeRoundedFavourite,
695
- size: 16, color: theme.hintColor),
716
+ HugeIcon(
717
+ icon: HugeIcons.strokeRoundedFavourite,
718
+ size: 16,
719
+ color: theme.hintColor,
720
+ ),
721
const SizedBox(width: 4),
722
Text(_formatCount(widget.model.likes)),
723
const SizedBox(width: 16),
724
],
725
if (widget.model.downloads > 0) ...[
701
- HugeIcon(icon: HugeIcons.strokeRoundedDownload01,
702
- size: 16, color: theme.hintColor),
726
+ HugeIcon(
727
+ icon: HugeIcons.strokeRoundedDownload01,
728
+ size: 16,
729
+ color: theme.hintColor,
730
+ ),
731
const SizedBox(width: 4),
732
Text(_formatCount(widget.model.downloads)),
733
],
@@ -725,21 +753,26 @@ class _ModelDetailPanelState extends ConsumerState<_ModelDetailPanel> {
753
if (widget.model.metadata.paramsStrings.isNotEmpty)
754
_MetaChip(
755
label: l10n.lm_studio_params,
728
- value: widget.model.metadata.paramsStrings
729
- .join(', '),
756
+ value: widget.model.metadata.paramsStrings.join(
757
+ ', ',
758
+ ),
759
),
760
if (widget.model.metadata.architectures.isNotEmpty)
761
_MetaChip(
762
label: l10n.lm_studio_arch,
734
- value: widget.model.metadata.architectures
735
- .join(', '),
763
+ value: widget.model.metadata.architectures.join(
764
+ ', ',
765
+ ),
766
),
767
_MetaChip(
768
label: l10n.lm_studio_domain,
769
value: widget.model.metadata.type.toUpperCase(),
770
),
771
if (widget
742
- .model.metadata.compatibilityTypes.isNotEmpty)
772
+ .model
773
+ .metadata
774
+ .compatibilityTypes
775
+ .isNotEmpty)
776
_MetaChip(
777
label: l10n.lm_studio_format,
778
value: widget.model.metadata.compatibilityTypes
@@ -811,8 +844,8 @@ class _ModelDetailPanelState extends ConsumerState<_ModelDetailPanel> {
844
value: activeJob.progressFraction,
845
),
846
)
814
- : HugeIcon(icon:
815
- selectedDownloaded
847
+ : HugeIcon(
848
+ icon: selectedDownloaded
849
? HugeIcons.strokeRoundedTick01
850
: HugeIcons.strokeRoundedDownload01,
851
),
@@ -823,12 +856,12 @@ class _ModelDetailPanelState extends ConsumerState<_ModelDetailPanel> {
856
.round(),
857
)
858
: selectedDownloaded
826
- ? l10n.downloaded
827
- : selectedSize != null
828
- ? l10n.lm_studio_download_size(
829
- formatBytes(selectedSize),
830
- )
831
- : l10n.lm_studio_download,
859
+ ? l10n.downloaded
860
+ : selectedSize != null
861
+ ? l10n.lm_studio_download_size(
862
+ formatBytes(selectedSize),
863
+ )
864
+ : l10n.lm_studio_download,
865
),
866
),
867
),
@@ -953,13 +986,31 @@ class _CapabilityRow extends StatelessWidget {
986
final l10n = AppLocalizations.of(context)!;
987
final caps = <Widget>[];
988
if (model.metadata.vision) {
956
- caps.add(_capLabel(HugeIcons.strokeRoundedEye, l10n.lm_studio_vision, Colors.amber));
989
+ caps.add(
990
+ _capLabel(
991
+ HugeIcons.strokeRoundedEye,
992
+ l10n.lm_studio_vision,
993
+ Colors.amber,
994
+ ),
995
+ );
996
}
997
if (model.metadata.trainedForToolUse) {
959
- caps.add(_capLabel(HugeIcons.strokeRoundedTools, l10n.lm_studio_tool_use, Colors.blue));
998
+ caps.add(
999
+ _capLabel(
1000
+ HugeIcons.strokeRoundedTools,
1001
+ l10n.lm_studio_tool_use,
1002
+ Colors.blue,
1003
+ ),
1004
+ );
1005
}
1006
if (model.metadata.reasoning) {
962
- caps.add(_capLabel(HugeIcons.strokeRoundedBrain, l10n.lm_studio_reasoning, Colors.green));
1007
+ caps.add(
1008
+ _capLabel(
1009
+ HugeIcons.strokeRoundedBrain,
1010
+ l10n.lm_studio_reasoning,
1011
+ Colors.green,
1012
+ ),
1013
+ );
1014
}
1015
if (caps.isEmpty) return const SizedBox.shrink();
1016
return Wrap(spacing: 8, runSpacing: 8, children: caps);
@@ -1026,10 +1077,14 @@ class _CompatibilityBadge extends StatelessWidget {
1077
const SizedBox(width: 6),
1078
Text(
1079
label,
1029
- style: TextStyle(color: color, fontWeight: FontWeight.w600, fontSize: 12),
1080
+ style: TextStyle(
1081
+ color: color,
1082
+ fontWeight: FontWeight.w600,
1083
+ fontSize: 12,
1084
+ ),
1085
),
1086
],
1087
),
1088
);
1089
}
1035
-}
\ No newline at end of file
1090
+}
lib/features/lm_studio_catalog/views/lm_studio_quant_selector.dart
+58
-22
@@ -51,7 +51,10 @@ class _LmStudioQuantSelectorState extends State<LmStudioQuantSelector> {
51
);
52
}
53
54
- Widget _compatBadge(MemoryCompatibility compatibility, AppLocalizations l10n) {
54
+ Widget _compatBadge(
55
+ MemoryCompatibility compatibility,
56
+ AppLocalizations l10n,
57
+ ) {
58
if (compatibility == MemoryCompatibility.unknown) {
59
return const SizedBox.shrink();
60
}
@@ -144,8 +147,10 @@ class _LmStudioQuantSelectorState extends State<LmStudioQuantSelector> {
147
overflow: TextOverflow.ellipsis,
148
),
149
),
147
- HugeIcon(icon:
148
- _expanded ? HugeIcons.strokeRoundedArrowUp01 : HugeIcons.strokeRoundedArrowDown01,
150
+ HugeIcon(
151
+ icon: _expanded
152
+ ? HugeIcons.strokeRoundedArrowUp01
153
+ : HugeIcons.strokeRoundedArrowDown01,
154
color: theme.hintColor,
155
),
156
],
@@ -157,7 +162,9 @@ class _LmStudioQuantSelectorState extends State<LmStudioQuantSelector> {
162
const SizedBox(height: 8),
163
Container(
164
decoration: BoxDecoration(
160
- color: isDark ? AppColors.darkBackground : AppColors.lightBackground,
165
+ color: isDark
166
+ ? AppColors.darkBackground
167
+ : AppColors.lightBackground,
168
borderRadius: BorderRadius.circular(12),
169
border: Border.all(
170
color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
@@ -191,11 +198,14 @@ class _LmStudioQuantSelectorState extends State<LmStudioQuantSelector> {
198
final downloaded = _isDownloaded(quant);
199
final compat = _compat(quant);
200
final isRecommended =
194
- recommended != null && quant.fileName == recommended.fileName;
201
+ recommended != null &&
202
+ quant.fileName == recommended.fileName;
203
return Material(
204
color: isSelected
197
- ? (isDark ? AppColors.darkAccent : AppColors.lightAccent)
198
- .withValues(alpha: 0.12)
205
+ ? (isDark
206
+ ? AppColors.darkAccent
207
+ : AppColors.lightAccent)
208
+ .withValues(alpha: 0.12)
209
: Colors.transparent,
210
child: InkWell(
211
onTap: () {
@@ -210,15 +220,15 @@ class _LmStudioQuantSelectorState extends State<LmStudioQuantSelector> {
220
child: Row(
221
crossAxisAlignment: CrossAxisAlignment.start,
222
children: [
213
- HugeIcon(icon:
214
- isSelected
223
+ HugeIcon(
224
+ icon: isSelected
225
? HugeIcons.strokeRoundedCheckmarkCircle01
226
: HugeIcons.strokeRoundedCircle,
227
size: 18,
228
color: isSelected
229
? (isDark
220
- ? AppColors.darkAccent
221
- : AppColors.lightAccent)
230
+ ? AppColors.darkAccent
231
+ : AppColors.lightAccent)
232
: theme.hintColor,
233
),
234
const SizedBox(width: 8),
@@ -229,21 +239,43 @@ class _LmStudioQuantSelectorState extends State<LmStudioQuantSelector> {
239
Wrap(
240
spacing: 6,
241
runSpacing: 4,
232
- crossAxisAlignment: WrapCrossAlignment.center,
242
+ crossAxisAlignment:
243
+ WrapCrossAlignment.center,
244
children: [
245
_GgufTag(compact: true),
246
Text(
247
quant.quantization,
248
style: theme.textTheme.titleSmall
238
- ?.copyWith(fontWeight: FontWeight.w600),
249
+ ?.copyWith(
250
+ fontWeight: FontWeight.w600,
251
+ ),
252
),
240
- if (widget.modelCapabilities.metadata.vision)
241
- _CapIcon(HugeIcons.strokeRoundedEye, Colors.amber),
242
- if (widget.modelCapabilities.metadata.reasoning)
243
- _CapIcon(HugeIcons.strokeRoundedBrain, Colors.green),
244
- if (widget.modelCapabilities.metadata.trainedForToolUse)
245
- _CapIcon(HugeIcons.strokeRoundedTools, Colors.blue),
246
- if (isRecommended) _RecommendedTag(l10n: l10n),
253
+ if (widget
254
+ .modelCapabilities
255
+ .metadata
256
+ .vision)
257
+ _CapIcon(
258
+ HugeIcons.strokeRoundedEye,
259
+ Colors.amber,
260
+ ),
261
+ if (widget
262
+ .modelCapabilities
263
+ .metadata
264
+ .reasoning)
265
+ _CapIcon(
266
+ HugeIcons.strokeRoundedBrain,
267
+ Colors.green,
268
+ ),
269
+ if (widget
270
+ .modelCapabilities
271
+ .metadata
272
+ .trainedForToolUse)
273
+ _CapIcon(
274
+ HugeIcons.strokeRoundedTools,
275
+ Colors.blue,
276
+ ),
277
+ if (isRecommended)
278
+ _RecommendedTag(l10n: l10n),
279
],
280
),
281
const SizedBox(height: 4),
@@ -273,7 +305,11 @@ class _LmStudioQuantSelectorState extends State<LmStudioQuantSelector> {
305
Row(
306
mainAxisSize: MainAxisSize.min,
307
children: [
276
- HugeIcon(icon: HugeIcons.strokeRoundedTick01, size: 14, color: Colors.green.shade400),
308
+ HugeIcon(
309
+ icon: HugeIcons.strokeRoundedTick01,
310
+ size: 14,
311
+ color: Colors.green.shade400,
312
+ ),
313
const SizedBox(width: 2),
314
Text(
315
l10n.downloaded,
@@ -373,4 +409,4 @@ class _RecommendedTag extends StatelessWidget {
409
/// Collect downloaded models from server catalog for download detection.
410
List<ModelInfo> downloadedModelsList(List<dynamic> models) {
411
return models.whereType<ModelInfo>().toList();
376
-}
\ No newline at end of file
412
+}
lib/features/mcp/views/mcp_tools_screen.dart
+6
-7
@@ -116,9 +116,7 @@ class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
116
value: settings.mcpEnabled,
117
badges: [_FeatureBadge(label: l10n.experimental_label)],
118
onChanged: (value) {
119
- ref
120
- .read(settingsProvider.notifier)
121
- .setMcpEnabled(value);
119
+ ref.read(settingsProvider.notifier).setMcpEnabled(value);
120
_refreshTools();
121
},
122
),
@@ -147,8 +145,9 @@ class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
145
if (context.mounted) {
146
ScaffoldMessenger.of(context).showSnackBar(
147
SnackBar(
150
- content:
151
- Text(l10n.calendar_permission_denied),
148
+ content: Text(
149
+ l10n.calendar_permission_denied,
150
+ ),
151
),
152
);
153
}
@@ -431,8 +430,8 @@ class _ToolRow extends StatelessWidget {
430
icon: isMcp
431
? HugeIcons.strokeRoundedShare01
432
: (tool.name.startsWith('calendar.')
434
- ? HugeIcons.strokeRoundedCalendar01
435
- : HugeIcons.strokeRoundedCalculate),
433
+ ? HugeIcons.strokeRoundedCalendar01
434
+ : HugeIcons.strokeRoundedCalculate),
435
size: 18,
436
color: isMcp
437
? theme.colorScheme.primary
lib/features/models/components/inline_thinking_selector.dart
+6
-5
@@ -84,21 +84,22 @@ class InlineThinkingSelector extends ConsumerWidget {
84
scrollDirection: Axis.horizontal,
85
child: Row(
86
children: options.map((opt) {
87
- final isActive = isSelected &&
87
+ final isActive =
88
+ isSelected &&
89
(opt.isEnabled == config.enabled) &&
90
(!opt.isEnabled || opt.effort == config.effort);
91
92
final chipBg = isActive
93
? accent.withValues(alpha: 0.2)
94
: (isDark
94
- ? Colors.white.withValues(alpha: 0.05)
95
- : Colors.black.withValues(alpha: 0.04));
95
+ ? Colors.white.withValues(alpha: 0.05)
96
+ : Colors.black.withValues(alpha: 0.04));
97
98
final chipFg = isActive
99
? accent
100
: (isDark
100
- ? AppColors.darkMutedText
101
- : AppColors.lightMutedText);
101
+ ? AppColors.darkMutedText
102
+ : AppColors.lightMutedText);
103
104
final borderColor = isActive
105
? accent.withValues(alpha: 0.5)
lib/features/models/components/model_context_length_section.dart
+5
-8
@@ -62,10 +62,9 @@ class _ModelContextLengthSectionState
62
if (conversationId == null) {
63
ref.read(settingsProvider.notifier).setContextLength(parsed);
64
} else {
65
- ref.read(conv.conversationsProvider.notifier).updateChatParams(
66
- conversationId,
67
- contextLength: parsed,
68
- );
65
+ ref
66
+ .read(conv.conversationsProvider.notifier)
67
+ .updateChatParams(conversationId, contextLength: parsed);
68
}
69
_lastValue = parsed;
70
}
@@ -95,10 +94,8 @@ class _ModelContextLengthSectionState
94
controller: _controller,
95
focusNode: _focusNode,
96
keyboardType: TextInputType.number,
98
- onSubmitted: (value) =>
99
- _saveValue(value, activeConv?.id),
100
- onEditingComplete: () =>
101
- _saveValue(_controller.text, activeConv?.id),
97
+ onSubmitted: (value) => _saveValue(value, activeConv?.id),
98
+ onEditingComplete: () => _saveValue(_controller.text, activeConv?.id),
99
),
100
const SizedBox(height: 4),
101
Text(
lib/features/models/components/model_search_field.dart
+7
-7
@@ -25,19 +25,19 @@ class ModelSearchField extends ConsumerWidget {
25
horizontal: 12,
26
vertical: 10,
27
),
28
- border: OutlineInputBorder(
29
- borderRadius: BorderRadius.circular(8),
30
- ),
28
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
29
suffixIcon: searchQuery.isNotEmpty
30
? IconButton(
33
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedCancel01, size: 18),
31
+ icon: const HugeIcon(
32
+ icon: HugeIcons.strokeRoundedCancel01,
33
+ size: 18,
34
+ ),
35
onPressed: () =>
36
ref.read(modelSearchQueryProvider.notifier).clear(),
37
)
38
: null,
39
),
39
- onChanged: (q) =>
40
- ref.read(modelSearchQueryProvider.notifier).setQuery(q),
40
+ onChanged: (q) => ref.read(modelSearchQueryProvider.notifier).setQuery(q),
41
);
42
}
43
-}
\ No newline at end of file
43
+}
lib/features/models/components/model_sort_control.dart
+10
-6
@@ -46,14 +46,17 @@ class ModelSortControl extends ConsumerWidget {
46
initialValue: current,
47
onSelected: (option) =>
48
ref.read(modelSortOptionProvider.notifier).setOption(option),
49
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedSlidersHorizontal, size: 20),
49
+ icon: const HugeIcon(
50
+ icon: HugeIcons.strokeRoundedSlidersHorizontal,
51
+ size: 20,
52
+ ),
53
itemBuilder: (context) => ModelSortOption.values.map((option) {
54
return PopupMenuItem(
55
value: option,
56
child: Row(
57
children: [
55
- HugeIcon(icon:
56
- _iconFor(option),
58
+ HugeIcon(
59
+ icon: _iconFor(option),
60
size: 18,
61
color: option == current
62
? Theme.of(context).colorScheme.primary
@@ -63,8 +66,9 @@ class ModelSortControl extends ConsumerWidget {
66
Text(
67
_labelFor(l10n, option),
68
style: TextStyle(
66
- fontWeight:
67
- option == current ? FontWeight.w600 : FontWeight.normal,
69
+ fontWeight: option == current
70
+ ? FontWeight.w600
71
+ : FontWeight.normal,
72
color: option == current
73
? Theme.of(context).colorScheme.primary
74
: null,
@@ -76,4 +80,4 @@ class ModelSortControl extends ConsumerWidget {
80
}).toList(),
81
);
82
}
79
-}
\ No newline at end of file
83
+}
lib/features/models/components/model_tile.dart
+6
-2
@@ -85,8 +85,12 @@ class ModelTile extends StatelessWidget {
85
),
86
decoration: BoxDecoration(
87
color: isDark
88
- ? AppColors.darkAccent.withValues(alpha: 0.18)
89
- : AppColors.lightAccent.withValues(alpha: 0.15),
88
+ ? AppColors.darkAccent.withValues(
89
+ alpha: 0.18,
90
+ )
91
+ : AppColors.lightAccent.withValues(
92
+ alpha: 0.15,
93
+ ),
94
borderRadius: BorderRadius.circular(4),
95
),
96
child: Text(
lib/features/models/components/no_server_state.dart
+6
-4
@@ -14,8 +14,8 @@ class NoServerState extends StatelessWidget {
14
child: Column(
15
mainAxisAlignment: MainAxisAlignment.center,
16
children: [
17
- HugeIcon(icon:
18
- HugeIcons.strokeRoundedComputer,
17
+ HugeIcon(
18
+ icon: HugeIcons.strokeRoundedComputer,
19
size: 48,
20
color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
21
),
@@ -33,11 +33,13 @@ class NoServerState extends StatelessWidget {
33
l10n.add_server_first,
34
style: TextStyle(
35
fontSize: 13,
36
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
36
+ color: isDark
37
+ ? AppColors.darkMutedText
38
+ : AppColors.lightMutedText,
39
),
40
),
41
],
42
),
43
);
44
}
43
-}
\ No newline at end of file
45
+}
lib/features/models/components/thinking_mode_chip.dart
+23
-22
@@ -159,29 +159,30 @@ class ThinkingModeChip extends ConsumerWidget {
159
notifier.setEnabled(true);
160
notifier.setEffort(effort);
161
},
162
- itemBuilder: (context) => effortsForModel(model?.supportedReasoningEfforts)
163
- .map(
164
- (effort) => PopupMenuItem(
165
- value: effort,
166
- child: Row(
167
- mainAxisSize: MainAxisSize.min,
168
- children: [
169
- HugeIcon(
170
- icon: effort == config.effort
171
- ? HugeIcons.strokeRoundedTick01
172
- : HugeIcons.strokeRoundedSquare01,
173
- size: 16,
174
- color: effort == config.effort
175
- ? accent
176
- : Colors.transparent,
162
+ itemBuilder: (context) =>
163
+ effortsForModel(model?.supportedReasoningEfforts)
164
+ .map(
165
+ (effort) => PopupMenuItem(
166
+ value: effort,
167
+ child: Row(
168
+ mainAxisSize: MainAxisSize.min,
169
+ children: [
170
+ HugeIcon(
171
+ icon: effort == config.effort
172
+ ? HugeIcons.strokeRoundedTick01
173
+ : HugeIcons.strokeRoundedSquare01,
174
+ size: 16,
175
+ color: effort == config.effort
176
+ ? accent
177
+ : Colors.transparent,
178
+ ),
179
+ const SizedBox(width: 8),
180
+ Text(_effortShortLabel(l10n, effort)),
181
+ ],
182
),
178
- const SizedBox(width: 8),
179
- Text(_effortShortLabel(l10n, effort)),
180
- ],
181
- ),
182
- ),
183
- )
184
- .toList(),
183
+ ),
184
+ )
185
+ .toList(),
186
child: Padding(
187
padding: EdgeInsetsDirectional.fromSTEB(
188
0,
lib/features/models/data/model_metadata_repository.dart
+5
-1
@@ -8,7 +8,11 @@ class ModelMetadata {
8
final bool isFavorite;
9
final String? note;
10
11
- ModelMetadata copyWith({bool? isFavorite, String? note, bool clearNote = false}) {
11
+ ModelMetadata copyWith({
12
+ bool? isFavorite,
13
+ String? note,
14
+ bool clearNote = false,
15
+ }) {
16
return ModelMetadata(
17
isFavorite: isFavorite ?? this.isFavorite,
18
note: clearNote ? null : (note ?? this.note),
lib/features/models/data/models/model_info.dart
+6
-2
@@ -20,18 +20,23 @@ class ModelInfo {
20
final bool supportsVision;
21
final bool supportsReasoning;
22
final bool supportsToolUse;
23
+
24
/// Effort strings the model advertises for reasoning (OpenRouter
25
/// `reasoning.supported_efforts`), e.g. `["minimal","low","medium","high"]`.
26
/// Null for providers that don't expose per-model efforts.
27
final List<String>? supportedReasoningEfforts;
28
+
29
/// The model's default reasoning effort (OpenRouter
30
/// `reasoning.default_effort`), e.g. `"medium"`.
31
final String? defaultReasoningEffort;
32
+
33
/// When true the model cannot run without reasoning (OpenRouter
34
/// `reasoning.mandatory`); the "Off" toggle should be hidden.
35
final bool reasoningMandatory;
36
+
37
/// Input token price in USD per 1M tokens (OpenRouter `pricing.prompt`).
38
final double? inputPricePerMillion;
39
+
40
/// Output token price in USD per 1M tokens (OpenRouter `pricing.completion`).
41
final double? outputPricePerMillion;
42
@@ -184,8 +189,7 @@ class ModelInfo {
189
defaultReasoningEffort:
190
defaultReasoningEffort ?? this.defaultReasoningEffort,
191
reasoningMandatory: reasoningMandatory ?? this.reasoningMandatory,
187
- inputPricePerMillion:
188
- inputPricePerMillion ?? this.inputPricePerMillion,
192
+ inputPricePerMillion: inputPricePerMillion ?? this.inputPricePerMillion,
193
outputPricePerMillion:
194
outputPricePerMillion ?? this.outputPricePerMillion,
195
);
lib/features/models/providers/model_metadata_providers.dart
+3
-1
@@ -3,7 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
3
import '../../../core/providers/storage_providers.dart';
4
import '../data/model_metadata_repository.dart';
5
6
-final modelMetadataRepositoryProvider = Provider<ModelMetadataRepository>((ref) {
6
+final modelMetadataRepositoryProvider = Provider<ModelMetadataRepository>((
7
+ ref,
8
+) {
9
return ModelMetadataRepository(ref.watch(sharedPreferencesProvider));
10
});
11
lib/features/models/providers/model_picker_providers.dart
+1
-3
@@ -15,9 +15,7 @@ class ModelSearchNotifier extends Notifier<String> {
15
enum ModelSortOption { favorites, nameAsc, sizeAsc, sizeDesc, contextDesc }
16
17
final modelSortOptionProvider =
18
- NotifierProvider<ModelSortNotifier, ModelSortOption>(
19
- ModelSortNotifier.new,
20
- );
18
+ NotifierProvider<ModelSortNotifier, ModelSortOption>(ModelSortNotifier.new);
19
20
class ModelSortNotifier extends Notifier<ModelSortOption> {
21
@override
lib/features/models/utils/model_instance_utils.dart
+2
-7
@@ -1,13 +1,8 @@
1
bool isModelKeyLoaded(Set<String> instanceIds, String modelKey) {
2
- return instanceIds.any(
3
- (id) => id == modelKey || id.startsWith('$modelKey:'),
4
- );
2
+ return instanceIds.any((id) => id == modelKey || id.startsWith('$modelKey:'));
3
}
4
7
-List<String> instanceIdsForModelKey(
8
- Set<String> instanceIds,
9
- String modelKey,
10
-) {
5
+List<String> instanceIdsForModelKey(Set<String> instanceIds, String modelKey) {
6
return instanceIds
7
.where((id) => id == modelKey || id.startsWith('$modelKey:'))
8
.toList();
lib/features/models/views/model_picker_sheet.dart
+5
-4
@@ -297,10 +297,11 @@ class _ModelPickerHeader extends ConsumerWidget {
297
vertical: 2,
298
),
299
decoration: BoxDecoration(
300
- color: (isDark
301
- ? AppColors.darkAccent
302
- : AppColors.lightAccent)
303
- .withValues(alpha: 0.15),
300
+ color:
301
+ (isDark
302
+ ? AppColors.darkAccent
303
+ : AppColors.lightAccent)
304
+ .withValues(alpha: 0.15),
305
borderRadius: BorderRadius.circular(999),
306
),
307
child: Text(
lib/features/on_device/components/memory_stats_panel.dart
+24
-13
@@ -94,8 +94,8 @@ class _MemoryPanelContent extends StatelessWidget {
94
children: [
95
Row(
96
children: [
97
- HugeIcon(icon:
98
- HugeIcons.strokeRoundedCpu,
97
+ HugeIcon(
98
+ icon: HugeIcons.strokeRoundedCpu,
99
size: 20,
100
color: theme.colorScheme.primary,
101
),
@@ -112,7 +112,10 @@ class _MemoryPanelContent extends StatelessWidget {
112
ShadButton.ghost(
113
size: ShadButtonSize.sm,
114
onPressed: () => ref.invalidate(deviceMemoryProvider),
115
- child: const HugeIcon(icon: HugeIcons.strokeRoundedRefresh, size: 18),
115
+ child: const HugeIcon(
116
+ icon: HugeIcons.strokeRoundedRefresh,
117
+ size: 18,
118
+ ),
119
),
120
],
121
),
@@ -127,7 +130,9 @@ class _MemoryPanelContent extends StatelessWidget {
130
Text(
131
l10n.ram_usage,
132
style: theme.textTheme.labelMedium?.copyWith(
130
- color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
133
+ color: theme.colorScheme.onSurface.withValues(
134
+ alpha: 0.6,
135
+ ),
136
),
137
),
138
Container(
@@ -199,7 +204,9 @@ class _MemoryPanelContent extends StatelessWidget {
204
Text(
205
'${_formatMb(usedMb)} / ${info.totalMemoryFormatted}',
206
style: theme.textTheme.labelSmall?.copyWith(
202
- color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
207
+ color: theme.colorScheme.onSurface.withValues(
208
+ alpha: 0.5,
209
+ ),
210
),
211
),
212
],
@@ -264,7 +271,8 @@ class _MemoryStat extends StatelessWidget {
271
@override
272
Widget build(BuildContext context) {
273
final theme = Theme.of(context);
267
- final iconColor = color ?? theme.colorScheme.onSurface.withValues(alpha: 0.4);
274
+ final iconColor =
275
+ color ?? theme.colorScheme.onSurface.withValues(alpha: 0.4);
276
277
return Column(
278
crossAxisAlignment: alignment,
@@ -292,12 +300,15 @@ class _MemoryStat extends StatelessWidget {
300
const SizedBox(height: 2),
301
Text(
302
value,
295
- style: (isPrimary ? theme.textTheme.titleMedium : theme.textTheme.bodyMedium)
296
- ?.copyWith(
297
- fontWeight: FontWeight.bold,
298
- color: color,
299
- letterSpacing: isPrimary ? -0.5 : null,
300
- ),
303
+ style:
304
+ (isPrimary
305
+ ? theme.textTheme.titleMedium
306
+ : theme.textTheme.bodyMedium)
307
+ ?.copyWith(
308
+ fontWeight: FontWeight.bold,
309
+ color: color,
310
+ letterSpacing: isPrimary ? -0.5 : null,
311
+ ),
312
),
313
],
314
);
@@ -309,4 +320,4 @@ String _formatMb(int mb) {
320
return '${(mb / 1024).toStringAsFixed(1)} GB';
321
}
322
return '$mb MB';
312
-}
\ No newline at end of file
323
+}
lib/features/on_device/components/model_card.dart
+9
-5
@@ -188,8 +188,8 @@ class _MemoryWarningBadge extends StatelessWidget {
188
child: Row(
189
mainAxisSize: MainAxisSize.min,
190
children: [
191
- const HugeIcon(icon:
192
- HugeIcons.strokeRoundedAlertCircle,
191
+ const HugeIcon(
192
+ icon: HugeIcons.strokeRoundedAlertCircle,
193
color: Colors.orange,
194
size: 14,
195
),
@@ -479,8 +479,12 @@ class _DownloadedActions extends ConsumerWidget {
479
borderRadius: BorderRadius.circular(2),
480
child: LinearProgressIndicator(
481
minHeight: 3,
482
- backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.15),
483
- valueColor: AlwaysStoppedAnimation<Color>(theme.colorScheme.primary),
482
+ backgroundColor: theme.colorScheme.primary.withValues(
483
+ alpha: 0.15,
484
+ ),
485
+ valueColor: AlwaysStoppedAnimation<Color>(
486
+ theme.colorScheme.primary,
487
+ ),
488
),
489
),
490
],
@@ -769,4 +773,4 @@ class _NotDownloadedActions extends ConsumerWidget {
773
message: message,
774
);
775
}
772
-}
\ No newline at end of file
776
+}
lib/features/on_device/data/notification_permission_service.dart
+8
-5
@@ -18,7 +18,8 @@ class NotificationPermissionService {
18
Future<bool> isPermissionGranted() async {
19
final androidImplementation = _notifications
20
.resolvePlatformSpecificImplementation<
21
- AndroidFlutterLocalNotificationsPlugin>();
21
+ AndroidFlutterLocalNotificationsPlugin
22
+ >();
23
if (androidImplementation != null) {
24
return await androidImplementation.areNotificationsEnabled() ?? false;
25
}
@@ -28,16 +29,18 @@ class NotificationPermissionService {
29
Future<bool> requestPermission() async {
30
final androidImplementation = _notifications
31
.resolvePlatformSpecificImplementation<
31
- AndroidFlutterLocalNotificationsPlugin>();
32
+ AndroidFlutterLocalNotificationsPlugin
33
+ >();
34
if (androidImplementation != null) {
33
- final granted =
34
- await androidImplementation.requestNotificationsPermission();
35
+ final granted = await androidImplementation
36
+ .requestNotificationsPermission();
37
return granted ?? false;
38
}
39
40
final iosImplementation = _notifications
41
.resolvePlatformSpecificImplementation<
40
- IOSFlutterLocalNotificationsPlugin>();
42
+ IOSFlutterLocalNotificationsPlugin
43
+ >();
44
if (iosImplementation != null) {
45
final granted = await iosImplementation.requestPermissions(
46
alert: true,
lib/features/on_device/data/on_device_llama_service.dart
+4
-1
@@ -194,7 +194,10 @@ class OnDeviceLlamaService {
194
}
195
final content = delta.content;
196
if (content != null && content.isNotEmpty) {
197
- yield ChatResponse(type: ChatResponseType.message, content: content);
197
+ yield ChatResponse(
198
+ type: ChatResponseType.message,
199
+ content: content,
200
+ );
201
}
202
}
203
_userMessageCount = userMessages.length;
lib/features/on_device/views/backend_selector.dart
+6
-5
@@ -37,8 +37,8 @@ class BackendSelector extends ConsumerWidget {
37
.setPreferredBackend(backend),
38
child: Row(
39
children: [
40
- HugeIcon(icon:
41
- isSelected
40
+ HugeIcon(
41
+ icon: isSelected
42
? HugeIcons.strokeRoundedCheckmarkCircle01
43
: HugeIcons.strokeRoundedCircle,
44
size: 20,
@@ -61,8 +61,9 @@ class BackendSelector extends ConsumerWidget {
61
_backendDescription(backend, context),
62
style: Theme.of(context).textTheme.bodySmall
63
?.copyWith(
64
- color: Theme.of(context).colorScheme.onSurface
65
- .withValues(alpha: 0.6),
64
+ color: Theme.of(
65
+ context,
66
+ ).colorScheme.onSurface.withValues(alpha: 0.6),
67
),
68
),
69
],
@@ -88,4 +89,4 @@ class BackendSelector extends ConsumerWidget {
89
return 'Neural Processing Unit';
90
}
91
}
91
-}
\ No newline at end of file
92
+}
lib/features/on_device/views/model_manager_screen.dart
+48
-21
@@ -288,7 +288,9 @@ class _ManagerHeader extends StatelessWidget {
288
value: _ImportAction.localFile,
289
child: ListTile(
290
contentPadding: EdgeInsets.zero,
291
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedFolder01),
291
+ leading: const HugeIcon(
292
+ icon: HugeIcons.strokeRoundedFolder01,
293
+ ),
294
title: Text(l10n.gguf_import_local_title),
295
subtitle: Text(l10n.gguf_import_local_subtitle),
296
),
@@ -297,7 +299,9 @@ class _ManagerHeader extends StatelessWidget {
299
value: _ImportAction.huggingFace,
300
child: ListTile(
301
contentPadding: EdgeInsets.zero,
300
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedCloudDownload),
302
+ leading: const HugeIcon(
303
+ icon: HugeIcons.strokeRoundedCloudDownload,
304
+ ),
305
title: Text(l10n.gguf_import_huggingface_title),
306
subtitle: Text(l10n.gguf_import_huggingface_subtitle),
307
),
@@ -317,8 +321,8 @@ class _ManagerHeader extends StatelessWidget {
321
child: Row(
322
mainAxisSize: MainAxisSize.min,
323
children: [
320
- HugeIcon(icon:
321
- HugeIcons.strokeRoundedAddCircle,
324
+ HugeIcon(
325
+ icon: HugeIcons.strokeRoundedAddCircle,
326
size: 18,
327
color: theme.colorScheme.primary,
328
),
@@ -378,8 +382,8 @@ class _ImportOverviewCard extends StatelessWidget {
382
color: theme.colorScheme.primary.withValues(alpha: 0.1),
383
borderRadius: BorderRadius.circular(12),
384
),
381
- child: HugeIcon(icon:
382
- HugeIcons.strokeRoundedCpu,
385
+ child: HugeIcon(
386
+ icon: HugeIcons.strokeRoundedCpu,
387
color: theme.colorScheme.primary,
388
),
389
),
@@ -440,7 +444,9 @@ class _ImportOverviewCard extends StatelessWidget {
444
),
445
OutlinedButton.icon(
446
onPressed: onImportFromHuggingFace,
443
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedCloudDownload),
447
+ icon: const HugeIcon(
448
+ icon: HugeIcons.strokeRoundedCloudDownload,
449
+ ),
450
label: Text(l10n.gguf_import_huggingface_title),
451
),
452
],
@@ -570,7 +576,9 @@ class _EmptyImportedModelsCard extends StatelessWidget {
576
),
577
OutlinedButton.icon(
578
onPressed: onImportFromHuggingFace,
573
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedCloudDownload),
579
+ icon: const HugeIcon(
580
+ icon: HugeIcons.strokeRoundedCloudDownload,
581
+ ),
582
label: Text(l10n.gguf_import_huggingface_title),
583
),
584
],
@@ -637,8 +645,8 @@ class _HuggingFaceGgufImportDialogState
645
color: theme.colorScheme.primary.withValues(alpha: 0.10),
646
borderRadius: BorderRadius.circular(12),
647
),
640
- child: HugeIcon(icon:
641
- HugeIcons.strokeRoundedCloudDownload,
648
+ child: HugeIcon(
649
+ icon: HugeIcons.strokeRoundedCloudDownload,
650
color: theme.colorScheme.primary,
651
),
652
),
@@ -687,7 +695,10 @@ class _HuggingFaceGgufImportDialogState
695
prefixIcon: const Center(
696
widthFactor: 1.0,
697
heightFactor: 1.0,
690
- child: HugeIcon(icon: HugeIcons.strokeRoundedLink01, size: 20),
698
+ child: HugeIcon(
699
+ icon: HugeIcons.strokeRoundedLink01,
700
+ size: 20,
701
+ ),
702
),
703
),
704
),
@@ -698,21 +709,30 @@ class _HuggingFaceGgufImportDialogState
709
children: [
710
OutlinedButton.icon(
711
onPressed: _isImporting ? null : _pasteFromClipboard,
701
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedClipboard, size: 18),
712
+ icon: const HugeIcon(
713
+ icon: HugeIcons.strokeRoundedClipboard,
714
+ size: 18,
715
+ ),
716
label: Text(l10n.paste),
717
),
718
OutlinedButton.icon(
719
onPressed: _isImporting || input.isEmpty
720
? null
721
: _clearInput,
708
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedCancel01, size: 18),
722
+ icon: const HugeIcon(
723
+ icon: HugeIcons.strokeRoundedCancel01,
724
+ size: 18,
725
+ ),
726
label: Text(l10n.clear_huggingface_token),
727
),
728
OutlinedButton.icon(
729
onPressed: _isImporting
730
? null
731
: () => _openHuggingFace(context),
715
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedShare01, size: 18),
732
+ icon: const HugeIcon(
733
+ icon: HugeIcons.strokeRoundedShare01,
734
+ size: 18,
735
+ ),
736
label: Text(l10n.gguf_browse),
737
),
738
],
@@ -735,8 +755,8 @@ class _HuggingFaceGgufImportDialogState
755
child: Row(
756
crossAxisAlignment: CrossAxisAlignment.start,
757
children: [
738
- HugeIcon(icon:
739
- hasHuggingFaceToken
758
+ HugeIcon(
759
+ icon: hasHuggingFaceToken
760
? HugeIcons.strokeRoundedCheckmarkBadge01
761
: HugeIcons.strokeRoundedInformationCircle,
762
size: 18,
@@ -854,8 +874,8 @@ class _HuggingFaceGgufImportDialogState
874
child: Row(
875
crossAxisAlignment: CrossAxisAlignment.start,
876
children: [
857
- const HugeIcon(icon:
858
- HugeIcons.strokeRoundedInformationCircle,
877
+ const HugeIcon(
878
+ icon: HugeIcons.strokeRoundedInformationCircle,
879
color: Colors.red,
880
size: 18,
881
),
@@ -889,7 +909,10 @@ class _HuggingFaceGgufImportDialogState
909
),
910
ElevatedButton.icon(
911
onPressed: _isImporting || input.isEmpty ? null : _startImport,
892
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedDownload01, size: 18),
912
+ icon: const HugeIcon(
913
+ icon: HugeIcons.strokeRoundedDownload01,
914
+ size: 18,
915
+ ),
916
label: Text(l10n.gguf_import_action),
917
),
918
],
@@ -1273,7 +1296,11 @@ class _EngineStatusCard extends StatelessWidget {
1296
),
1297
child: Row(
1298
children: [
1276
- const HugeIcon(icon: HugeIcons.strokeRoundedCpu, color: Colors.green, size: 20),
1299
+ const HugeIcon(
1300
+ icon: HugeIcons.strokeRoundedCpu,
1301
+ color: Colors.green,
1302
+ size: 20,
1303
+ ),
1304
const SizedBox(width: 8),
1305
Expanded(
1306
child: Text(
@@ -1314,4 +1341,4 @@ class _EngineStatusCard extends StatelessWidget {
1341
1342
return const SizedBox.shrink();
1343
}
1317
-}
\ No newline at end of file
1344
+}
lib/features/onboarding/screens/onboarding_language_screen.dart
+28
-24
@@ -284,11 +284,11 @@ class _OnboardingLanguageScreenState
284
physics: const NeverScrollableScrollPhysics(),
285
gridDelegate:
286
const SliverGridDelegateWithFixedCrossAxisCount(
287
- crossAxisCount: 2,
288
- mainAxisSpacing: 12,
289
- crossAxisSpacing: 12,
290
- mainAxisExtent: 92,
291
- ),
287
+ crossAxisCount: 2,
288
+ mainAxisSpacing: 12,
289
+ crossAxisSpacing: 12,
290
+ mainAxisExtent: 92,
291
+ ),
292
itemCount: _languages.length,
293
itemBuilder: (context, index) {
294
final lang = _languages[index];
@@ -343,8 +343,9 @@ class _OnboardingLanguageScreenState
343
height: 24,
344
decoration: BoxDecoration(
345
color: Colors.white,
346
- borderRadius:
347
- BorderRadius.circular(5),
346
+ borderRadius: BorderRadius.circular(
347
+ 5,
348
+ ),
349
border: Border.all(
350
color: theme.colorScheme.outline
351
.withValues(alpha: 0.15),
@@ -362,22 +363,23 @@ class _OnboardingLanguageScreenState
363
),
364
alignment: Alignment.center,
365
child: ClipRRect(
365
- borderRadius:
366
- BorderRadius.circular(4),
366
+ borderRadius: BorderRadius.circular(
367
+ 4,
368
+ ),
369
child: Image.asset(
370
lang.flagAsset,
371
width: 36,
372
height: 24,
373
fit: BoxFit.cover,
372
- errorBuilder: (context, error,
373
- stackTrace) {
374
- return Text(
375
- lang.flag,
376
- style: const TextStyle(
377
- fontSize: 14,
378
- ),
379
- );
380
- },
374
+ errorBuilder:
375
+ (context, error, stackTrace) {
376
+ return Text(
377
+ lang.flag,
378
+ style: const TextStyle(
379
+ fontSize: 14,
380
+ ),
381
+ );
382
+ },
383
),
384
),
385
),
@@ -422,8 +424,8 @@ class _OnboardingLanguageScreenState
424
overflow: TextOverflow.ellipsis,
425
style: theme.textTheme.titleSmall
426
?.copyWith(
425
- fontWeight: FontWeight.bold,
426
- ),
427
+ fontWeight: FontWeight.bold,
428
+ ),
429
),
430
const SizedBox(height: 2),
431
Text(
@@ -432,10 +434,12 @@ class _OnboardingLanguageScreenState
434
overflow: TextOverflow.ellipsis,
435
style: theme.textTheme.labelSmall
436
?.copyWith(
435
- color: theme.colorScheme.onSurface
436
- .withValues(alpha: 0.5),
437
- fontSize: 10,
438
- ),
437
+ color: theme
438
+ .colorScheme
439
+ .onSurface
440
+ .withValues(alpha: 0.5),
441
+ fontSize: 10,
442
+ ),
443
),
444
],
445
),
lib/features/onboarding/screens/onboarding_model_download_screen.dart
+17
-9
@@ -82,8 +82,9 @@ class _OnboardingModelDownloadScreenState
82
),
83
child: Row(
84
children: [
85
- const HugeIcon(icon:
86
- HugeIcons.strokeRoundedInformationCircle,
85
+ const HugeIcon(
86
+ icon: HugeIcons
87
+ .strokeRoundedInformationCircle,
88
color: Colors.orange,
89
size: 20,
90
),
@@ -280,7 +281,11 @@ class _MemoryStat extends StatelessWidget {
281
final theme = Theme.of(context);
282
return Row(
283
children: [
283
- HugeIcon(icon: icon, size: 16, color: color ?? theme.colorScheme.primary),
284
+ HugeIcon(
285
+ icon: icon,
286
+ size: 16,
287
+ color: color ?? theme.colorScheme.primary,
288
+ ),
289
const SizedBox(width: 8),
290
Column(
291
crossAxisAlignment: CrossAxisAlignment.start,
@@ -382,8 +387,8 @@ class _ModelCard extends ConsumerWidget {
387
padding: const EdgeInsets.only(top: 4),
388
child: Row(
389
children: [
385
- const HugeIcon(icon:
386
- HugeIcons.strokeRoundedAlertCircle,
390
+ const HugeIcon(
391
+ icon: HugeIcons.strokeRoundedAlertCircle,
392
color: Colors.orange,
393
size: 14,
394
),
@@ -426,7 +431,11 @@ class _ModelCard extends ConsumerWidget {
431
if (isDownloaded)
432
Row(
433
children: [
429
- const HugeIcon(icon: HugeIcons.strokeRoundedCheckmarkCircle01, color: Colors.green, size: 18),
434
+ const HugeIcon(
435
+ icon: HugeIcons.strokeRoundedCheckmarkCircle01,
436
+ color: Colors.green,
437
+ size: 18,
438
+ ),
439
const SizedBox(width: 4),
440
Text(
441
l10n.downloaded,
@@ -526,8 +535,7 @@ class _ModelCard extends ConsumerWidget {
535
if (model.supportsVision) 'Vision',
536
model.languagesLabel,
537
if (model.backendNote != null) model.backendNote!,
529
- if (model.requiresHuggingFaceToken)
530
- l10n.model_requires_huggingface_token,
538
+ if (model.requiresHuggingFaceToken) l10n.model_requires_huggingface_token,
539
];
540
541
return Wrap(
@@ -594,4 +602,4 @@ class _ModelCard extends ConsumerWidget {
602
requiredRam: requiredRam,
603
);
604
}
597
-}
\ No newline at end of file
605
+}
lib/features/onboarding/screens/onboarding_notification_permission_screen.dart
+17
-20
@@ -41,7 +41,9 @@ class _OnboardingNotificationPermissionScreenState
41
Future<void> _requestPermission() async {
42
setState(() => _isProcessing = true);
43
try {
44
- final notificationService = ref.read(notificationPermissionServiceProvider);
44
+ final notificationService = ref.read(
45
+ notificationPermissionServiceProvider,
46
+ );
47
await notificationService.requestPermission();
48
await _completeOnboarding();
49
} finally {
@@ -61,17 +63,17 @@ class _OnboardingNotificationPermissionScreenState
63
child: Cue.onMount(
64
motion: .smooth(),
65
child: Padding(
64
- padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 32.0),
66
+ padding: const EdgeInsets.symmetric(
67
+ horizontal: 24.0,
68
+ vertical: 32.0,
69
+ ),
70
child: Column(
71
crossAxisAlignment: CrossAxisAlignment.center,
72
children: [
73
const Spacer(),
74
// Visual element
75
Actor(
71
- acts: [
72
- .fadeIn(),
73
- .scale(from: 0.8),
74
- ],
76
+ acts: [.fadeIn(), .scale(from: 0.8)],
77
child: Container(
78
padding: const EdgeInsets.all(32),
79
decoration: BoxDecoration(
@@ -90,10 +92,7 @@ class _OnboardingNotificationPermissionScreenState
92
const SizedBox(height: 48),
93
Actor(
94
delay: 60.ms,
93
- acts: [
94
- .fadeIn(),
95
- .slideY(from: 0.08),
96
- ],
95
+ acts: [.fadeIn(), .slideY(from: 0.08)],
96
child: Column(
97
children: [
98
Text(
@@ -107,7 +106,9 @@ class _OnboardingNotificationPermissionScreenState
106
Text(
107
l10n.stay_updated_desc,
108
style: theme.textTheme.titleMedium?.copyWith(
110
- color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
109
+ color: theme.colorScheme.onSurface.withValues(
110
+ alpha: 0.7,
111
+ ),
112
),
113
textAlign: TextAlign.center,
114
),
@@ -118,10 +119,7 @@ class _OnboardingNotificationPermissionScreenState
119
// Benefits list
120
Actor(
121
delay: 120.ms,
121
- acts: [
122
- .fadeIn(),
123
- .slideY(from: 0.08),
124
- ],
122
+ acts: [.fadeIn(), .slideY(from: 0.08)],
123
child: Column(
124
children: [
125
_buildBenefitRow(
@@ -148,10 +146,7 @@ class _OnboardingNotificationPermissionScreenState
146
const SizedBox(height: 32),
147
Actor(
148
delay: 180.ms,
151
- acts: [
152
- .fadeIn(),
153
- .slideY(from: 0.08),
154
- ],
149
+ acts: [.fadeIn(), .slideY(from: 0.08)],
150
child: Column(
151
crossAxisAlignment: CrossAxisAlignment.stretch,
152
children: [
@@ -163,7 +158,9 @@ class _OnboardingNotificationPermissionScreenState
158
width: 20,
159
child: CircularProgressIndicator(
160
strokeWidth: 2,
166
- valueColor: AlwaysStoppedAnimation(Colors.white),
161
+ valueColor: AlwaysStoppedAnimation(
162
+ Colors.white,
163
+ ),
164
),
165
)
166
: Text(
lib/features/onboarding/screens/onboarding_server_type_screen.dart
+28
-28
@@ -99,10 +99,7 @@ class _OnboardingServerTypeScreenState
99
crossAxisAlignment: CrossAxisAlignment.stretch,
100
children: [
101
Actor(
102
- acts: [
103
- .fadeIn(),
104
- .slideY(from: 0.08),
105
- ],
102
+ acts: [.fadeIn(), .slideY(from: 0.08)],
103
child: Column(
104
crossAxisAlignment: CrossAxisAlignment.stretch,
105
children: [
@@ -120,7 +117,9 @@ class _OnboardingServerTypeScreenState
117
l10n.onboarding_connect_desc,
118
textAlign: TextAlign.center,
119
style: theme.textTheme.bodySmall?.copyWith(
123
- color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
120
+ color: theme.colorScheme.onSurface.withValues(
121
+ alpha: 0.7,
122
+ ),
123
height: 1.5,
124
),
125
),
@@ -129,14 +128,12 @@ class _OnboardingServerTypeScreenState
128
),
129
Actor(
130
delay: 60.ms,
132
- acts: [
133
- .fadeIn(),
134
- .slideY(from: 0.08),
135
- ],
131
+ acts: [.fadeIn(), .slideY(from: 0.08)],
132
child: LayoutBuilder(
133
builder: (context, constraints) {
138
- final crossAxisCount =
139
- constraints.maxWidth >= 560 ? 3 : 2;
134
+ final crossAxisCount = constraints.maxWidth >= 560
135
+ ? 3
136
+ : 2;
137
138
return Column(
139
children: [
@@ -155,7 +152,8 @@ class _OnboardingServerTypeScreenState
152
subtitle: l10n.server_type_on_device_desc,
153
iconWidget: HugeIcon(
154
icon: HugeIcons.strokeRoundedSmartPhone01,
158
- color: _selectedType == ServerType.onDevice
155
+ color:
156
+ _selectedType == ServerType.onDevice
157
? theme.colorScheme.primary
158
: theme.colorScheme.onSurface,
159
),
@@ -166,8 +164,10 @@ class _OnboardingServerTypeScreenState
164
title: l10n.server_type_lm_studio,
165
subtitle: l10n.server_type_lm_studio_desc,
166
iconWidget: HugeIcon(
169
- icon: HugeIcons.strokeRoundedComputerTerminal01,
170
- color: _selectedType == ServerType.lmStudio
167
+ icon: HugeIcons
168
+ .strokeRoundedComputerTerminal01,
169
+ color:
170
+ _selectedType == ServerType.lmStudio
171
? theme.colorScheme.primary
172
: theme.colorScheme.onSurface,
173
),
@@ -205,7 +205,9 @@ class _OnboardingServerTypeScreenState
205
subtitle: l10n.server_type_ollama_cloud_sub,
206
iconWidget: HugeIcon(
207
icon: HugeIcons.strokeRoundedAiCloud,
208
- color: _selectedType == ServerType.ollamaCloud
208
+ color:
209
+ _selectedType ==
210
+ ServerType.ollamaCloud
211
? theme.colorScheme.primary
212
: theme.colorScheme.onSurface,
213
),
@@ -217,7 +219,8 @@ class _OnboardingServerTypeScreenState
219
subtitle: l10n.server_type_openrouter_sub,
220
iconWidget: HugeIcon(
221
icon: HugeIcons.strokeRoundedCloudServer,
220
- color: _selectedType == ServerType.openRouter
222
+ color:
223
+ _selectedType == ServerType.openRouter
224
? theme.colorScheme.primary
225
: theme.colorScheme.onSurface,
226
),
@@ -243,10 +246,7 @@ class _OnboardingServerTypeScreenState
246
),
247
Actor(
248
delay: 120.ms,
246
- acts: [
247
- .fadeIn(),
248
- .slideY(from: 0.08),
249
- ],
249
+ acts: [.fadeIn(), .slideY(from: 0.08)],
250
child: Padding(
251
padding: const EdgeInsets.symmetric(horizontal: 24.0),
252
child: Column(
@@ -352,7 +352,7 @@ class _OnboardingServerTypeScreenState
352
decoration: BoxDecoration(
353
color: disabled
354
? theme.colorScheme.surface.withValues(alpha: 0.5)
355
- : theme.colorScheme.surface,
355
+ : theme.colorScheme.surface,
356
borderRadius: BorderRadius.circular(14),
357
border: Border.all(
358
color: isSelected && !disabled
@@ -361,11 +361,11 @@ class _OnboardingServerTypeScreenState
361
width: isSelected && !disabled ? 1.5 : 1,
362
),
363
),
364
- child: Column(
365
- crossAxisAlignment: CrossAxisAlignment.start,
366
- children: [
367
- Row(
368
- children: [
364
+ child: Column(
365
+ crossAxisAlignment: CrossAxisAlignment.start,
366
+ children: [
367
+ Row(
368
+ children: [
369
Container(
370
padding: const EdgeInsets.all(8),
371
decoration: BoxDecoration(
@@ -384,8 +384,8 @@ class _OnboardingServerTypeScreenState
384
),
385
const Spacer(),
386
if (disabled)
387
- const HugeIcon(icon:
388
- HugeIcons.strokeRoundedLock,
387
+ const HugeIcon(
388
+ icon: HugeIcons.strokeRoundedLock,
389
size: 18,
390
color: Colors.orange,
391
),
lib/features/onboarding/screens/onboarding_theme_screen.dart
+15
-15
@@ -39,15 +39,15 @@ class _OnboardingThemeScreenState extends ConsumerState<OnboardingThemeScreen> {
39
child: Cue.onMount(
40
motion: .smooth(),
41
child: Padding(
42
- padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
42
+ padding: const EdgeInsets.symmetric(
43
+ horizontal: 24.0,
44
+ vertical: 16.0,
45
+ ),
46
child: Column(
47
crossAxisAlignment: CrossAxisAlignment.stretch,
48
children: [
49
Actor(
47
- acts: [
48
- .fadeIn(),
49
- .slideY(from: 0.08),
50
- ],
50
+ acts: [.fadeIn(), .slideY(from: 0.08)],
51
child: Text(
52
l10n.choose_theme_desc,
53
style: theme.textTheme.titleMedium?.copyWith(
@@ -60,10 +60,7 @@ class _OnboardingThemeScreenState extends ConsumerState<OnboardingThemeScreen> {
60
Expanded(
61
child: Actor(
62
delay: 60.ms,
63
- acts: [
64
- .fadeIn(),
65
- .slideY(from: 0.08),
66
- ],
63
+ acts: [.fadeIn(), .slideY(from: 0.08)],
64
child: ListView(
65
children: [
66
_buildThemeCard(
@@ -129,10 +126,7 @@ class _OnboardingThemeScreenState extends ConsumerState<OnboardingThemeScreen> {
126
const SizedBox(height: 24),
127
Actor(
128
delay: 120.ms,
132
- acts: [
133
- .fadeIn(),
134
- .slideY(from: 0.08),
135
- ],
129
+ acts: [.fadeIn(), .slideY(from: 0.08)],
130
child: ShadButton(
131
width: double.infinity,
132
onPressed: () async {
@@ -142,7 +136,10 @@ class _OnboardingThemeScreenState extends ConsumerState<OnboardingThemeScreen> {
136
},
137
child: Text(
138
l10n.finish_setup,
145
- style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
139
+ style: const TextStyle(
140
+ fontSize: 16,
141
+ fontWeight: FontWeight.w600,
142
+ ),
143
),
144
),
145
),
@@ -213,7 +210,10 @@ class _OnboardingThemeScreenState extends ConsumerState<OnboardingThemeScreen> {
210
),
211
),
212
if (isSelected)
216
- HugeIcon(icon: HugeIcons.strokeRoundedCheckmarkCircle01, color: theme.colorScheme.primary),
213
+ HugeIcon(
214
+ icon: HugeIcons.strokeRoundedCheckmarkCircle01,
215
+ color: theme.colorScheme.primary,
216
+ ),
217
],
218
),
219
),
lib/features/personas/providers/personas_providers.dart
+6
-5
@@ -367,8 +367,9 @@ final personaByIdProvider = Provider.family<Persona?, String>((ref, id) {
367
}
368
});
369
370
-final personasForConversationProvider =
371
- Provider.family<List<Persona>, String?>((ref, personaIdsRaw) {
372
- final personas = ref.watch(personasNotifierProvider).value ?? [];
373
- return PersonaPromptUtils.resolvePersonas(personaIdsRaw, personas);
374
-});
370
+final personasForConversationProvider = Provider.family<List<Persona>, String?>(
371
+ (ref, personaIdsRaw) {
372
+ final personas = ref.watch(personasNotifierProvider).value ?? [];
373
+ return PersonaPromptUtils.resolvePersonas(personaIdsRaw, personas);
374
+ },
375
+);
lib/features/personas/views/components/persona_picker_sheet.dart
+20
-15
@@ -48,10 +48,7 @@ class _PersonaPickerSheetState extends ConsumerState<PersonaPickerSheet> {
48
49
List<String> _initialSelectedIds() {
50
if (widget.mode == PersonaPickerMode.preselection) {
51
- return ref
52
- .read(selectedPersonasProvider)
53
- .map((p) => p.id)
54
- .toList();
51
+ return ref.read(selectedPersonasProvider).map((p) => p.id).toList();
52
}
53
final activeConv = ref.read(conv.activeConversationProvider);
54
return PersonaPromptUtils.parsePersonaIds(activeConv?.personaId);
@@ -126,8 +123,7 @@ class _PersonaPickerSheetState extends ConsumerState<PersonaPickerSheet> {
123
),
124
if (_selectedIds.isNotEmpty)
125
TextButton(
129
- onPressed: () =>
130
- _clear(personasAsync.value ?? const []),
126
+ onPressed: () => _clear(personasAsync.value ?? const []),
127
child: Text(l10n.clear_personas),
128
),
129
TextButton(
@@ -148,7 +144,10 @@ class _PersonaPickerSheetState extends ConsumerState<PersonaPickerSheet> {
144
.firstOrNull;
145
if (persona == null) return const SizedBox.shrink();
146
return InputChip(
151
- avatar: Text(persona.emoji, style: const TextStyle(fontSize: 14)),
147
+ avatar: Text(
148
+ persona.emoji,
149
+ style: const TextStyle(fontSize: 14),
150
+ ),
151
label: Text(persona.name),
152
onDeleted: () =>
153
_toggle(persona, personasAsync.value ?? const []),
@@ -192,8 +191,8 @@ class _PersonaPickerSheetState extends ConsumerState<PersonaPickerSheet> {
191
final personas = selectedCategory == null
192
? allPersonas
193
: allPersonas
195
- .where((p) => p.category == selectedCategory)
196
- .toList();
194
+ .where((p) => p.category == selectedCategory)
195
+ .toList();
196
if (personas.isEmpty) {
197
return Padding(
198
padding: const EdgeInsets.all(24),
@@ -220,17 +219,23 @@ class _PersonaPickerSheetState extends ConsumerState<PersonaPickerSheet> {
219
contentPadding: EdgeInsets.zero,
220
leading: CircleAvatar(
221
backgroundColor: selected
223
- ? theme.colorScheme.primary.withValues(alpha: 0.15)
222
+ ? theme.colorScheme.primary.withValues(
223
+ alpha: 0.15,
224
+ )
225
: (isDark
225
- ? AppColors.darkSurface
226
- : AppColors.lightSurface),
227
- child: Text(persona.emoji, style: const TextStyle(fontSize: 20)),
226
+ ? AppColors.darkSurface
227
+ : AppColors.lightSurface),
228
+ child: Text(
229
+ persona.emoji,
230
+ style: const TextStyle(fontSize: 20),
231
+ ),
232
),
233
title: Text(
234
persona.name,
235
style: TextStyle(
232
- fontWeight:
233
- selected ? FontWeight.w700 : FontWeight.w500,
236
+ fontWeight: selected
237
+ ? FontWeight.w700
238
+ : FontWeight.w500,
239
),
240
),
241
subtitle: persona.description != null
lib/features/personas/views/create_persona_screen.dart
+25
-11
@@ -145,7 +145,9 @@ class _CreatePersonaScreenState extends ConsumerState<CreatePersonaScreen> {
145
context.pop();
146
ScaffoldMessenger.of(context).showSnackBar(
147
SnackBar(
148
- content: Text(_isEditing ? l10n.persona_updated : l10n.persona_created),
148
+ content: Text(
149
+ _isEditing ? l10n.persona_updated : l10n.persona_created,
150
+ ),
151
),
152
);
153
}
@@ -153,7 +155,9 @@ class _CreatePersonaScreenState extends ConsumerState<CreatePersonaScreen> {
155
if (mounted) {
156
ScaffoldMessenger.of(context).showSnackBar(
157
SnackBar(
156
- content: Text(AppLocalizations.of(context)!.error_with_message(e.toString())),
158
+ content: Text(
159
+ AppLocalizations.of(context)!.error_with_message(e.toString()),
160
+ ),
161
backgroundColor: Colors.red,
162
),
163
);
@@ -316,8 +320,10 @@ class _CreatePersonaScreenState extends ConsumerState<CreatePersonaScreen> {
320
),
321
const SizedBox(width: 8),
322
IconButton(
319
- icon: HugeIcon(icon:
320
- _showPreview ? HugeIcons.strokeRoundedPencilEdit02 : HugeIcons.strokeRoundedEye,
323
+ icon: HugeIcon(
324
+ icon: _showPreview
325
+ ? HugeIcons.strokeRoundedPencilEdit02
326
+ : HugeIcons.strokeRoundedEye,
327
size: 18,
328
),
329
tooltip: _showPreview ? l10n.edit : l10n.preview,
@@ -383,8 +389,10 @@ class _CreatePersonaScreenState extends ConsumerState<CreatePersonaScreen> {
389
onTap: () => setState(() => _showAdvanced = !_showAdvanced),
390
child: Row(
391
children: [
386
- HugeIcon(icon:
387
- _showAdvanced ? HugeIcons.strokeRoundedArrowUp01 : HugeIcons.strokeRoundedArrowDown01,
392
+ HugeIcon(
393
+ icon: _showAdvanced
394
+ ? HugeIcons.strokeRoundedArrowUp01
395
+ : HugeIcons.strokeRoundedArrowDown01,
396
color: isDark
397
? const Color(0xFF888888)
398
: const Color(0xFF999999),
@@ -420,8 +428,11 @@ class _CreatePersonaScreenState extends ConsumerState<CreatePersonaScreen> {
428
validator: (v) {
429
if (v == null || v.trim().isEmpty) return null;
430
final val = double.tryParse(v.trim());
423
- if (val == null || val < 0 || val > 2) return l10n.range_0_2;
424
- return null;
431
+ if (val == null || val < 0 || val > 2) {
432
+ return l10n.range_0_2;
433
+ } else {
434
+ return null;
435
+ }
436
},
437
),
438
),
@@ -439,8 +450,11 @@ class _CreatePersonaScreenState extends ConsumerState<CreatePersonaScreen> {
450
validator: (v) {
451
if (v == null || v.trim().isEmpty) return null;
452
final val = double.tryParse(v.trim());
442
- if (val == null || val < 0 || val > 1) return l10n.range_0_1;
443
- return null;
453
+ if (val == null || val < 0 || val > 1) {
454
+ return l10n.range_0_1;
455
+ } else {
456
+ return null;
457
+ }
458
},
459
),
460
),
@@ -478,4 +492,4 @@ class _CreatePersonaScreenState extends ConsumerState<CreatePersonaScreen> {
492
),
493
);
494
}
481
-}
\ No newline at end of file
495
+}
lib/features/personas/views/persona_list_screen.dart
+187
-176
@@ -34,191 +34,197 @@ class PersonaListScreen extends ConsumerWidget {
34
top: topPadding + 8,
35
bottom: 16,
36
),
37
- decoration: BoxDecoration(
38
- color: isDark
39
- ? const Color(0xFF0A0A0A)
40
- : const Color(0xFFFAFAFA),
41
- border: Border(
42
- bottom: BorderSide(
43
- color: isDark
44
- ? const Color(0xFF2A2A2A)
45
- : const Color(0xFFE5E5E5),
46
- ),
37
+ decoration: BoxDecoration(
38
+ color: isDark
39
+ ? const Color(0xFF0A0A0A)
40
+ : const Color(0xFFFAFAFA),
41
+ border: Border(
42
+ bottom: BorderSide(
43
+ color: isDark
44
+ ? const Color(0xFF2A2A2A)
45
+ : const Color(0xFFE5E5E5),
46
),
47
),
49
- child: Row(
50
- children: [
51
- Builder(
52
- builder: (context) => IconButton(
53
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedMenu01),
54
- onPressed: () => Scaffold.of(context).openDrawer(),
55
- ),
48
+ ),
49
+ child: Row(
50
+ children: [
51
+ Builder(
52
+ builder: (context) => IconButton(
53
+ icon: const HugeIcon(icon: HugeIcons.strokeRoundedMenu01),
54
+ onPressed: () => Scaffold.of(context).openDrawer(),
55
),
57
- const SizedBox(width: 8),
58
- Text(
59
- l10n.personas_title,
60
- style: TextStyle(
61
- fontSize: 20,
62
- fontWeight: FontWeight.bold,
63
- color: isDark ? Colors.white : Colors.black,
64
- ),
56
+ ),
57
+ const SizedBox(width: 8),
58
+ Text(
59
+ l10n.personas_title,
60
+ style: TextStyle(
61
+ fontSize: 20,
62
+ fontWeight: FontWeight.bold,
63
+ color: isDark ? Colors.white : Colors.black,
64
),
66
- const Spacer(),
67
- IconButton(
68
- icon: HugeIcon(icon:
69
- previewSystemPrompts
70
- ? HugeIcons.strokeRoundedEye
71
- : HugeIcons.strokeRoundedEye,
72
- ),
73
- tooltip: l10n.preview_system_prompts,
74
- onPressed: () => ref
75
- .read(personaPreviewSystemPromptsProvider.notifier)
76
- .toggle(),
65
+ ),
66
+ const Spacer(),
67
+ IconButton(
68
+ icon: HugeIcon(
69
+ icon: previewSystemPrompts
70
+ ? HugeIcons.strokeRoundedEye
71
+ : HugeIcons.strokeRoundedEye,
72
),
78
- ],
79
- ),
80
- ),
81
- Padding(
82
- padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
83
- child: Text(
84
- l10n.personas_combine_hint,
85
- style: TextStyle(
86
- fontSize: 13,
87
- color: isDark
88
- ? AppColors.darkMutedText
89
- : AppColors.lightMutedText,
73
+ tooltip: l10n.preview_system_prompts,
74
+ onPressed: () => ref
75
+ .read(personaPreviewSystemPromptsProvider.notifier)
76
+ .toggle(),
77
),
78
+ ],
79
+ ),
80
+ ),
81
+ Padding(
82
+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
83
+ child: Text(
84
+ l10n.personas_combine_hint,
85
+ style: TextStyle(
86
+ fontSize: 13,
87
+ color: isDark
88
+ ? AppColors.darkMutedText
89
+ : AppColors.lightMutedText,
90
),
91
),
93
- SizedBox(
94
- height: 44,
95
- child: ListView(
96
- scrollDirection: Axis.horizontal,
97
- padding: const EdgeInsets.symmetric(horizontal: 12),
98
- children:
99
- [
100
- l10n.all,
101
- l10n.persona_category_general,
102
- l10n.persona_category_coding,
103
- l10n.persona_category_education,
104
- l10n.persona_category_creative,
105
- ].map((cat) {
106
- final isActive =
107
- selectedCategory == (cat == l10n.all ? null : cat);
108
- return Padding(
109
- padding: const EdgeInsets.symmetric(
110
- horizontal: 4,
111
- vertical: 6,
112
- ),
113
- child: FilterChip(
114
- label: Text(
115
- cat,
116
- style: TextStyle(
117
- fontSize: 13,
118
- fontWeight: isActive
119
- ? FontWeight.w600
120
- : FontWeight.normal,
121
- color: isActive
122
- ? (isDark ? Colors.white : Colors.white)
123
- : (isDark
124
- ? const Color(0xFFAAAAAA)
125
- : const Color(0xFF666666)),
126
- ),
127
- ),
128
- selected: isActive,
129
- onSelected: (_) {
130
- ref
131
- .read(personaCategoryFilterProvider.notifier)
132
- .setCategory(cat == l10n.all ? null : cat);
133
- },
134
- selectedColor: isDark
135
- ? const Color(0xFF3B82F6)
136
- : const Color(0xFF2563EB),
137
- backgroundColor: isDark
138
- ? const Color(0xFF1F1F1F)
139
- : const Color(0xFFF5F5F5),
140
- side: BorderSide(
92
+ ),
93
+ SizedBox(
94
+ height: 44,
95
+ child: ListView(
96
+ scrollDirection: Axis.horizontal,
97
+ padding: const EdgeInsets.symmetric(horizontal: 12),
98
+ children:
99
+ [
100
+ l10n.all,
101
+ l10n.persona_category_general,
102
+ l10n.persona_category_coding,
103
+ l10n.persona_category_education,
104
+ l10n.persona_category_creative,
105
+ ].map((cat) {
106
+ final isActive =
107
+ selectedCategory == (cat == l10n.all ? null : cat);
108
+ return Padding(
109
+ padding: const EdgeInsets.symmetric(
110
+ horizontal: 4,
111
+ vertical: 6,
112
+ ),
113
+ child: FilterChip(
114
+ label: Text(
115
+ cat,
116
+ style: TextStyle(
117
+ fontSize: 13,
118
+ fontWeight: isActive
119
+ ? FontWeight.w600
120
+ : FontWeight.normal,
121
color: isActive
142
- ? (isDark
143
- ? const Color(0xFF3B82F6)
144
- : const Color(0xFF2563EB))
122
+ ? (isDark ? Colors.white : Colors.white)
123
: (isDark
146
- ? const Color(0xFF3A3A3A)
147
- : const Color(0xFFE5E5E5)),
148
- ),
149
- showCheckmark: false,
150
- shape: RoundedRectangleBorder(
151
- borderRadius: BorderRadius.circular(20),
124
+ ? const Color(0xFFAAAAAA)
125
+ : const Color(0xFF666666)),
126
),
153
- padding: const EdgeInsets.symmetric(horizontal: 8),
154
- materialTapTargetSize:
155
- MaterialTapTargetSize.shrinkWrap,
127
),
157
- );
158
- }).toList(),
159
- ),
160
- ),
161
- Expanded(
162
- child: filteredPersonas.isEmpty
163
- ? _EmptyState(isDark: isDark, l10n: l10n)
164
- : ListView(
165
- padding: const EdgeInsets.symmetric(
166
- horizontal: 16,
167
- vertical: 8,
128
+ selected: isActive,
129
+ onSelected: (_) {
130
+ ref
131
+ .read(personaCategoryFilterProvider.notifier)
132
+ .setCategory(cat == l10n.all ? null : cat);
133
+ },
134
+ selectedColor: isDark
135
+ ? const Color(0xFF3B82F6)
136
+ : const Color(0xFF2563EB),
137
+ backgroundColor: isDark
138
+ ? const Color(0xFF1F1F1F)
139
+ : const Color(0xFFF5F5F5),
140
+ side: BorderSide(
141
+ color: isActive
142
+ ? (isDark
143
+ ? const Color(0xFF3B82F6)
144
+ : const Color(0xFF2563EB))
145
+ : (isDark
146
+ ? const Color(0xFF3A3A3A)
147
+ : const Color(0xFFE5E5E5)),
148
+ ),
149
+ showCheckmark: false,
150
+ shape: RoundedRectangleBorder(
151
+ borderRadius: BorderRadius.circular(20),
152
+ ),
153
+ padding: const EdgeInsets.symmetric(horizontal: 8),
154
+ materialTapTargetSize:
155
+ MaterialTapTargetSize.shrinkWrap,
156
),
169
- children: [
170
- if (userCreated.isNotEmpty) ...[
171
- _SectionLabel(label: l10n.persona_my_section, isDark: isDark),
172
- ...userCreated.map(
173
- (p) => _PersonaCard(
174
- persona: p,
175
- isDark: isDark,
176
- l10n: l10n,
177
- onTap: () => context.push(
178
- AppRoutes.createPersona,
179
- extra: p,
180
- ),
181
- onLongPress: () => _showActions(
182
- context,
183
- ref,
184
- p,
185
- isBuiltIn: false,
186
- ),
157
+ );
158
+ }).toList(),
159
+ ),
160
+ ),
161
+ Expanded(
162
+ child: filteredPersonas.isEmpty
163
+ ? _EmptyState(isDark: isDark, l10n: l10n)
164
+ : ListView(
165
+ padding: const EdgeInsets.symmetric(
166
+ horizontal: 16,
167
+ vertical: 8,
168
+ ),
169
+ children: [
170
+ if (userCreated.isNotEmpty) ...[
171
+ _SectionLabel(
172
+ label: l10n.persona_my_section,
173
+ isDark: isDark,
174
+ ),
175
+ ...userCreated.map(
176
+ (p) => _PersonaCard(
177
+ persona: p,
178
+ isDark: isDark,
179
+ l10n: l10n,
180
+ onTap: () => context.push(
181
+ AppRoutes.createPersona,
182
+ extra: p,
183
+ ),
184
+ onLongPress: () => _showActions(
185
+ context,
186
+ ref,
187
+ p,
188
+ isBuiltIn: false,
189
),
190
),
189
- const SizedBox(height: 12),
190
- ],
191
- if (builtIn.isNotEmpty) ...[
192
- _SectionLabel(label: l10n.persona_builtin_section, isDark: isDark),
193
- ...builtIn.map(
194
- (p) => _PersonaCard(
195
- persona: p,
196
- isDark: isDark,
197
- l10n: l10n,
198
- showSystemPrompt: previewSystemPrompts,
199
- onLongPress: () => _showActions(
200
- context,
201
- ref,
202
- p,
203
- isBuiltIn: true,
204
- ),
191
+ ),
192
+ const SizedBox(height: 12),
193
+ ],
194
+ if (builtIn.isNotEmpty) ...[
195
+ _SectionLabel(
196
+ label: l10n.persona_builtin_section,
197
+ isDark: isDark,
198
+ ),
199
+ ...builtIn.map(
200
+ (p) => _PersonaCard(
201
+ persona: p,
202
+ isDark: isDark,
203
+ l10n: l10n,
204
+ showSystemPrompt: previewSystemPrompts,
205
+ onLongPress: () => _showActions(
206
+ context,
207
+ ref,
208
+ p,
209
+ isBuiltIn: true,
210
),
211
),
207
- ],
208
- const SizedBox(height: 80),
212
+ ),
213
],
210
- ),
211
- ),
212
- ],
213
- ),
214
- PositionedDirectional(
215
- bottom: 24,
216
- end: 24,
217
- child: FloatingActionButton(
218
- onPressed: () => context.push(AppRoutes.createPersona),
219
- child: const HugeIcon(icon: HugeIcons.strokeRoundedAdd01),
214
+ const SizedBox(height: 80),
215
+ ],
216
+ ),
217
),
218
+ ],
219
+ ),
220
+ PositionedDirectional(
221
+ bottom: 24,
222
+ end: 24,
223
+ child: FloatingActionButton(
224
+ onPressed: () => context.push(AppRoutes.createPersona),
225
+ child: const HugeIcon(icon: HugeIcons.strokeRoundedAdd01),
226
),
227
+ ),
228
],
229
);
230
}
@@ -239,7 +245,9 @@ class PersonaListScreen extends ConsumerWidget {
245
children: [
246
if (!isBuiltIn)
247
ListTile(
242
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedPencilEdit02),
248
+ leading: const HugeIcon(
249
+ icon: HugeIcons.strokeRoundedPencilEdit02,
250
+ ),
251
title: Text(sheetL10n.edit),
252
onTap: () {
253
Navigator.pop(ctx);
@@ -247,7 +255,10 @@ class PersonaListScreen extends ConsumerWidget {
255
},
256
),
257
ListTile(
250
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: Colors.red),
258
+ leading: const HugeIcon(
259
+ icon: HugeIcons.strokeRoundedDelete01,
260
+ color: Colors.red,
261
+ ),
262
title: Text(
263
sheetL10n.delete,
264
style: const TextStyle(color: Colors.red),
@@ -436,10 +447,10 @@ class _PersonaCard extends StatelessWidget {
447
showSystemPrompt && persona.isBuiltIn
448
? persona.systemPrompt
449
: (persona.description ??
439
- persona.systemPrompt.substring(
440
- 0,
441
- persona.systemPrompt.length.clamp(0, 60),
442
- )),
450
+ persona.systemPrompt.substring(
451
+ 0,
452
+ persona.systemPrompt.length.clamp(0, 60),
453
+ )),
454
style: TextStyle(
455
fontSize: showSystemPrompt && persona.isBuiltIn ? 12 : 13,
456
color: isDark
@@ -482,8 +493,8 @@ class _PersonaCard extends StatelessWidget {
493
),
494
),
495
const SizedBox(width: 8),
485
- HugeIcon(icon:
486
- HugeIcons.strokeRoundedArrowRight01,
496
+ HugeIcon(
497
+ icon: HugeIcons.strokeRoundedArrowRight01,
498
size: 20,
499
color: isDark ? const Color(0xFF555555) : const Color(0xFFCCCCCC),
500
),
@@ -507,8 +518,8 @@ class _EmptyState extends StatelessWidget {
518
child: Column(
519
mainAxisAlignment: MainAxisAlignment.center,
520
children: [
510
- HugeIcon(icon:
511
- HugeIcons.strokeRoundedRobot01,
521
+ HugeIcon(
522
+ icon: HugeIcons.strokeRoundedRobot01,
523
size: 64,
524
color: isDark ? const Color(0xFF444444) : const Color(0xFFCCCCCC),
525
),
@@ -537,4 +548,4 @@ class _EmptyState extends StatelessWidget {
548
),
549
);
550
}
540
-}
\ No newline at end of file
551
+}
lib/features/saved_messages/views/components/save_message_sheet.dart
+11
-7
@@ -44,8 +44,8 @@ Future<void> showSaveMessageSheet(
44
),
45
),
46
ListTile(
47
- leading: HugeIcon(icon:
48
- selectedFolderId == null
47
+ leading: HugeIcon(
48
+ icon: selectedFolderId == null
49
? HugeIcons.strokeRoundedCheckmarkCircle01
50
: HugeIcons.strokeRoundedCircle,
51
),
@@ -54,8 +54,8 @@ Future<void> showSaveMessageSheet(
54
),
55
...folders.map(
56
(folder) => ListTile(
57
- leading: HugeIcon(icon:
58
- selectedFolderId == folder.id
57
+ leading: HugeIcon(
58
+ icon: selectedFolderId == folder.id
59
? HugeIcons.strokeRoundedCheckmarkCircle01
60
: HugeIcons.strokeRoundedCircle,
61
),
@@ -73,14 +73,18 @@ Future<void> showSaveMessageSheet(
73
ref.invalidate(isMessageSavedProvider(message.id));
74
if (context.mounted) Navigator.pop(context);
75
},
76
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedBookmarkRemove01),
76
+ icon: const HugeIcon(
77
+ icon: HugeIcons.strokeRoundedBookmarkRemove01,
78
+ ),
79
label: Text(l10n.remove_from_saved),
80
),
81
const SizedBox(height: 8),
82
ShadButton(
83
width: double.infinity,
84
onPressed: () async {
83
- await ref.read(savedMessagesProvider.notifier).saveMessage(
85
+ await ref
86
+ .read(savedMessagesProvider.notifier)
87
+ .saveMessage(
88
message,
89
folderId: selectedFolderId,
90
isTemporaryChat: isTemporaryChat,
@@ -96,4 +100,4 @@ Future<void> showSaveMessageSheet(
100
},
101
),
102
);
99
-}
\ No newline at end of file
103
+}
lib/features/saved_messages/views/components/saved_message_folder_sheet.dart
+1
-1
@@ -59,4 +59,4 @@ Future<void> showSavedMessagesBulkMoveToFolderSheet(
59
);
60
},
61
);
62
-}
\ No newline at end of file
62
+}
lib/features/saved_messages/views/components/saved_message_picker_sheet.dart
+1
-2
@@ -56,8 +56,7 @@ class _SavedMessagePickerSheet extends ConsumerWidget {
56
},
57
);
58
},
59
- loading: () =>
60
- const Center(child: CircularProgressIndicator()),
59
+ loading: () => const Center(child: CircularProgressIndicator()),
60
error: (e, _) => Center(child: Text(e.toString())),
61
),
62
),
lib/features/saved_messages/views/components/saved_message_picker_tile.dart
+7
-9
@@ -23,8 +23,9 @@ class SavedMessagePickerTile extends StatelessWidget {
23
final role = MessageRole.values[saved.roleIndex];
24
final isUser = role == MessageRole.user;
25
final isFromTempChat = saved.conversationId.isEmpty;
26
- final mutedColor =
27
- isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
26
+ final mutedColor = isDark
27
+ ? AppColors.darkMutedText
28
+ : AppColors.lightMutedText;
29
30
return Material(
31
color: Colors.transparent,
@@ -35,8 +36,8 @@ class SavedMessagePickerTile extends StatelessWidget {
36
child: Row(
37
crossAxisAlignment: CrossAxisAlignment.start,
38
children: [
38
- HugeIcon(icon:
39
- isUser
39
+ HugeIcon(
40
+ icon: isUser
41
? HugeIcons.strokeRoundedUser
42
: HugeIcons.strokeRoundedSparkles,
43
size: 20,
@@ -76,10 +77,7 @@ class SavedMessagePickerTile extends StatelessWidget {
77
const SizedBox(height: 2),
78
Text(
79
saved.content,
79
- style: TextStyle(
80
- fontSize: 13,
81
- color: mutedColor,
82
- ),
80
+ style: TextStyle(fontSize: 13, color: mutedColor),
81
maxLines: 3,
82
overflow: TextOverflow.ellipsis,
83
),
@@ -92,4 +90,4 @@ class SavedMessagePickerTile extends StatelessWidget {
90
),
91
);
92
}
95
-}
\ No newline at end of file
93
+}
lib/features/saved_messages/views/components/saved_message_tile.dart
+92
-81
@@ -36,8 +36,9 @@ class SavedMessageTile extends StatelessWidget {
36
final theme = Theme.of(context);
37
final isDark = theme.brightness == Brightness.dark;
38
final isFromTempChat = saved.conversationId.isEmpty;
39
- final mutedColor =
40
- isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
39
+ final mutedColor = isDark
40
+ ? AppColors.darkMutedText
41
+ : AppColors.lightMutedText;
42
43
return Dismissible(
44
key: Key(saved.id),
@@ -46,8 +47,10 @@ class SavedMessageTile extends StatelessWidget {
47
alignment: AlignmentDirectional.centerStart,
48
padding: const EdgeInsetsDirectional.only(start: 16),
49
color: Colors.blue,
49
- child: HugeIcon(icon:
50
- saved.isArchived ? HugeIcons.strokeRoundedArchive : HugeIcons.strokeRoundedArchive,
50
+ child: HugeIcon(
51
+ icon: saved.isArchived
52
+ ? HugeIcons.strokeRoundedArchive
53
+ : HugeIcons.strokeRoundedArchive,
54
color: Colors.white,
55
),
56
),
@@ -55,7 +58,10 @@ class SavedMessageTile extends StatelessWidget {
58
alignment: AlignmentDirectional.centerEnd,
59
padding: const EdgeInsetsDirectional.only(end: 16),
60
color: Colors.red,
58
- child: const HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: Colors.white),
61
+ child: const HugeIcon(
62
+ icon: HugeIcons.strokeRoundedDelete01,
63
+ color: Colors.white,
64
+ ),
65
),
66
confirmDismiss: (direction) async {
67
if (direction == DismissDirection.startToEnd) {
@@ -66,87 +72,87 @@ class SavedMessageTile extends StatelessWidget {
72
return false;
73
},
74
child: Material(
69
- color: Colors.transparent,
70
- child: InkWell(
71
- onTap: onTap,
72
- onLongPress: () => _showContextMenu(context, l10n, isDark),
73
- child: Container(
74
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
75
- child: Row(
76
- crossAxisAlignment: CrossAxisAlignment.start,
77
- children: [
78
- if (selectionMode)
79
- Checkbox(value: isSelected, onChanged: (_) => onTap())
80
- else
81
- HugeIcon(icon:
82
- isUser
83
- ? HugeIcons.strokeRoundedUser
84
- : HugeIcons.strokeRoundedSparkles,
85
- size: 20,
86
- color: mutedColor,
87
- ),
88
- const SizedBox(width: 12),
89
- Expanded(
90
- child: Column(
91
- crossAxisAlignment: CrossAxisAlignment.start,
92
- children: [
93
- if (isFromTempChat)
94
- Text(
95
- l10n.temporary_chat,
96
- style: TextStyle(
97
- fontSize: 14,
98
- fontWeight: FontWeight.w500,
99
- fontStyle: FontStyle.italic,
100
- color: mutedColor,
75
+ color: Colors.transparent,
76
+ child: InkWell(
77
+ onTap: onTap,
78
+ onLongPress: () => _showContextMenu(context, l10n, isDark),
79
+ child: Container(
80
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
81
+ child: Row(
82
+ crossAxisAlignment: CrossAxisAlignment.start,
83
+ children: [
84
+ if (selectionMode)
85
+ Checkbox(value: isSelected, onChanged: (_) => onTap())
86
+ else
87
+ HugeIcon(
88
+ icon: isUser
89
+ ? HugeIcons.strokeRoundedUser
90
+ : HugeIcons.strokeRoundedSparkles,
91
+ size: 20,
92
+ color: mutedColor,
93
+ ),
94
+ const SizedBox(width: 12),
95
+ Expanded(
96
+ child: Column(
97
+ crossAxisAlignment: CrossAxisAlignment.start,
98
+ children: [
99
+ if (isFromTempChat)
100
+ Text(
101
+ l10n.temporary_chat,
102
+ style: TextStyle(
103
+ fontSize: 14,
104
+ fontWeight: FontWeight.w500,
105
+ fontStyle: FontStyle.italic,
106
+ color: mutedColor,
107
+ ),
108
+ maxLines: 1,
109
+ overflow: TextOverflow.ellipsis,
110
+ )
111
+ else if (saved.conversationTitle.isNotEmpty)
112
+ Text(
113
+ saved.conversationTitle,
114
+ style: TextStyle(
115
+ fontSize: 14,
116
+ fontWeight: FontWeight.w600,
117
+ color: isDark
118
+ ? AppColors.darkPrimaryText
119
+ : AppColors.lightPrimaryText,
120
+ ),
121
+ maxLines: 1,
122
+ overflow: TextOverflow.ellipsis,
123
),
102
- maxLines: 1,
103
- overflow: TextOverflow.ellipsis,
104
- )
105
- else if (saved.conversationTitle.isNotEmpty)
124
+ if (isFromTempChat || saved.conversationTitle.isNotEmpty)
125
+ const SizedBox(height: 2),
126
Text(
107
- saved.conversationTitle,
108
- style: TextStyle(
109
- fontSize: 14,
110
- fontWeight: FontWeight.w600,
111
- color: isDark
112
- ? AppColors.darkPrimaryText
113
- : AppColors.lightPrimaryText,
114
- ),
115
- maxLines: 1,
127
+ saved.content,
128
+ style: TextStyle(fontSize: 13, color: mutedColor),
129
+ maxLines: 3,
130
overflow: TextOverflow.ellipsis,
131
),
118
- if (isFromTempChat || saved.conversationTitle.isNotEmpty)
119
- const SizedBox(height: 2),
120
- Text(
121
- saved.content,
122
- style: TextStyle(
123
- fontSize: 13,
124
- color: mutedColor,
125
- ),
126
- maxLines: 3,
127
- overflow: TextOverflow.ellipsis,
128
- ),
129
- ],
132
+ ],
133
+ ),
134
),
131
- ),
132
- const SizedBox(width: 4),
133
- IconButton(
134
- onPressed: () => _showContextMenu(context, l10n, isDark),
135
- icon: HugeIcon(
136
- icon: HugeIcons.strokeRoundedMoreVertical,
137
- size: 18,
138
- color: mutedColor,
135
+ const SizedBox(width: 4),
136
+ IconButton(
137
+ onPressed: () => _showContextMenu(context, l10n, isDark),
138
+ icon: HugeIcon(
139
+ icon: HugeIcons.strokeRoundedMoreVertical,
140
+ size: 18,
141
+ color: mutedColor,
142
+ ),
143
+ padding: EdgeInsets.zero,
144
+ constraints: const BoxConstraints(
145
+ minWidth: 32,
146
+ minHeight: 32,
147
+ ),
148
+ visualDensity: VisualDensity.compact,
149
+ tooltip: l10n.options_tooltip,
150
),
140
- padding: EdgeInsets.zero,
141
- constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
142
- visualDensity: VisualDensity.compact,
143
- tooltip: l10n.options_tooltip,
144
- ),
145
- ],
151
+ ],
152
+ ),
153
),
154
),
155
),
149
- ),
156
);
157
}
158
@@ -164,7 +170,9 @@ class SavedMessageTile extends StatelessWidget {
170
children: [
171
if (onEnterSelectionMode != null)
172
ListTile(
167
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedCheckList),
173
+ leading: const HugeIcon(
174
+ icon: HugeIcons.strokeRoundedCheckList,
175
+ ),
176
title: Text(l10n.select),
177
onTap: () {
178
Navigator.pop(ctx);
@@ -188,8 +196,8 @@ class SavedMessageTile extends StatelessWidget {
196
},
197
),
198
ListTile(
191
- leading: HugeIcon(icon:
192
- saved.isArchived
199
+ leading: HugeIcon(
200
+ icon: saved.isArchived
201
? HugeIcons.strokeRoundedArchive
202
: HugeIcons.strokeRoundedArchive,
203
),
@@ -202,7 +210,10 @@ class SavedMessageTile extends StatelessWidget {
210
},
211
),
212
ListTile(
205
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: Colors.red),
213
+ leading: const HugeIcon(
214
+ icon: HugeIcons.strokeRoundedDelete01,
215
+ color: Colors.red,
216
+ ),
217
title: Text(
218
l10n.delete,
219
style: const TextStyle(color: Colors.red),
lib/features/saved_messages/views/saved_messages_screen.dart
+20
-7
@@ -51,7 +51,9 @@ class SavedMessagesScreen extends ConsumerWidget {
51
? Row(
52
children: [
53
IconButton(
54
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedCancel01),
54
+ icon: const HugeIcon(
55
+ icon: HugeIcons.strokeRoundedCancel01,
56
+ ),
57
onPressed: () => ref
58
.read(savedMessageSelectionModeProvider.notifier)
59
.disable(),
@@ -67,12 +69,17 @@ class SavedMessagesScreen extends ConsumerWidget {
69
),
70
),
71
IconButton(
70
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedFolder01),
72
+ icon: const HugeIcon(
73
+ icon: HugeIcons.strokeRoundedFolder01,
74
+ ),
75
tooltip: l10n.move_to_folder,
76
onPressed: selectedIds.isEmpty
77
? null
78
: () => showSavedMessagesBulkMoveToFolderSheet(
75
- context, ref, selectedIds),
79
+ context,
80
+ ref,
81
+ selectedIds,
82
+ ),
83
),
84
],
85
)
@@ -80,7 +87,9 @@ class SavedMessagesScreen extends ConsumerWidget {
87
children: [
88
Builder(
89
builder: (context) => IconButton(
83
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedMenu01),
90
+ icon: const HugeIcon(
91
+ icon: HugeIcons.strokeRoundedMenu01,
92
+ ),
93
onPressed: () => Scaffold.of(context).openDrawer(),
94
),
95
),
@@ -95,7 +104,9 @@ class SavedMessagesScreen extends ConsumerWidget {
104
),
105
const Spacer(),
106
IconButton(
98
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedCheckList),
107
+ icon: const HugeIcon(
108
+ icon: HugeIcons.strokeRoundedCheckList,
109
+ ),
110
tooltip: l10n.select,
111
onPressed: () => ref
112
.read(savedMessageSelectionModeProvider.notifier)
@@ -187,7 +198,9 @@ class SavedMessagesScreen extends ConsumerWidget {
198
if (saved.conversationId.isEmpty) {
199
ScaffoldMessenger.of(context).showSnackBar(
200
SnackBar(
190
- content: Text(l10n.saved_message_temp_snap_unavailable),
201
+ content: Text(
202
+ l10n.saved_message_temp_snap_unavailable,
203
+ ),
204
),
205
);
206
return;
@@ -251,4 +264,4 @@ class SavedMessagesScreen extends ConsumerWidget {
264
],
265
);
266
}
254
-}
\ No newline at end of file
267
+}
lib/features/servers/data/models/server.dart
+11
-5
@@ -10,7 +10,9 @@ Uri? parseServerAddressInput(String input) {
10
final trimmed = input.trim();
11
if (trimmed.isEmpty) return null;
12
13
- final candidate = _hasExplicitHttpScheme(trimmed) ? trimmed : 'http://$trimmed';
13
+ final candidate = _hasExplicitHttpScheme(trimmed)
14
+ ? trimmed
15
+ : 'http://$trimmed';
16
final uri = Uri.tryParse(candidate);
17
if (uri == null || uri.host.isEmpty) return null;
18
if (uri.scheme != 'http' && uri.scheme != 'https') return null;
@@ -130,8 +132,10 @@ class Server {
132
final ConnectionStatus status;
133
final String? iconName;
134
final String? pathPrefix;
135
+
136
/// Optional system RAM in GB for model compatibility hints in the browser.
137
final int? availableRamGb;
138
+
139
/// Optional GPU VRAM in GB for model compatibility hints in the browser.
140
final int? availableVramGb;
141
@@ -302,10 +306,12 @@ class Server {
306
status: status ?? this.status,
307
iconName: iconName ?? this.iconName,
308
pathPrefix: clearPathPrefix ? null : (pathPrefix ?? this.pathPrefix),
305
- availableRamGb:
306
- clearAvailableRamGb ? null : (availableRamGb ?? this.availableRamGb),
307
- availableVramGb:
308
- clearAvailableVramGb ? null : (availableVramGb ?? this.availableVramGb),
309
+ availableRamGb: clearAvailableRamGb
310
+ ? null
311
+ : (availableRamGb ?? this.availableRamGb),
312
+ availableVramGb: clearAvailableVramGb
313
+ ? null
314
+ : (availableVramGb ?? this.availableVramGb),
315
);
316
}
317
}
lib/features/servers/data/server_api_service.dart
+50
-38
@@ -210,7 +210,9 @@ class ServerApiService {
210
}
211
return modelId;
212
} on DioException catch (e) {
213
- throw Exception(_extractApiErrorMessage(e.response?.data) ?? e.message);
213
+ throw Exception(
214
+ _extractApiErrorMessage(e.response?.data) ?? e.message,
215
+ );
216
}
217
case ServerType.ollama:
218
case ServerType.ollamaCloud:
@@ -225,7 +227,10 @@ class ServerApiService {
227
}
228
}
229
228
- Future<void> unloadAllInstances(Server server, Set<String> instanceIds) async {
230
+ Future<void> unloadAllInstances(
231
+ Server server,
232
+ Set<String> instanceIds,
233
+ ) async {
234
for (final instanceId in instanceIds) {
235
await unloadModel(server, instanceId, instanceId: instanceId);
236
}
@@ -270,11 +275,7 @@ class ServerApiService {
275
case ServerType.ollama:
276
await _dio.post(
277
server.unloadModelEndpoint,
273
- data: {
274
- 'model': modelId,
275
- 'keep_alive': 0,
276
- 'prompt': '',
277
- },
278
+ data: {'model': modelId, 'keep_alive': 0, 'prompt': ''},
279
options: Options(headers: buildServerAuthHeaders(server)),
280
);
281
break;
@@ -326,7 +327,8 @@ class ServerApiService {
327
if (modelItems is List) {
328
for (final item in modelItems) {
329
if (item is! Map) continue;
329
- final id = item['key']?.toString() ??
330
+ final id =
331
+ item['key']?.toString() ??
332
item['name']?.toString() ??
333
item['id']?.toString() ??
334
'';
@@ -386,7 +388,8 @@ class ServerApiService {
388
if (modelItems != null && modelItems is List) {
389
for (final item in modelItems) {
390
if (item is! Map) continue;
389
- final id = item['key']?.toString() ??
391
+ final id =
392
+ item['key']?.toString() ??
393
item['name']?.toString() ??
394
item['id']?.toString() ??
395
'';
@@ -395,7 +398,7 @@ class ServerApiService {
398
final displayName = item['display_name']?.toString();
399
final quantization = item['quantization'];
400
final paramsString = item['params_string']?.toString();
398
-
401
+
402
final meta = metaById[id] ?? (item['meta'] as Map<String, dynamic>?);
403
if (meta == null && metaById.isNotEmpty && data['models'] != null) {
404
Log.warning('Metadata join failed for model: $id');
@@ -422,12 +425,13 @@ class ServerApiService {
425
id: id,
426
name: displayName ?? _formatModelName(id),
427
description: item['description']?.toString(),
425
- parameterCount: _parseParameterString(paramsString) ??
428
+ parameterCount:
429
+ _parseParameterString(paramsString) ??
430
_paramCountFromMeta(finalMeta['n_params']),
427
- contextLength: _toInt(item['max_context_length']) ??
431
+ contextLength:
432
+ _toInt(item['max_context_length']) ??
433
_toInt(finalMeta['n_ctx_train']),
429
- fileSize: _toInt(item['size_bytes']) ??
430
- _toInt(finalMeta['size']),
434
+ fileSize: _toInt(item['size_bytes']) ?? _toInt(finalMeta['size']),
435
quantization: quantName,
436
architecture: archName,
437
serverType: server.type,
@@ -450,11 +454,8 @@ class ServerApiService {
454
return null;
455
}
456
453
- ({
454
- bool supportsVision,
455
- bool supportsReasoning,
456
- bool supportsToolUse,
457
- }) _parseModelCapabilities(dynamic raw) {
457
+ ({bool supportsVision, bool supportsReasoning, bool supportsToolUse})
458
+ _parseModelCapabilities(dynamic raw) {
459
if (raw is! Map) {
460
return (
461
supportsVision: false,
@@ -568,7 +569,8 @@ class ServerApiService {
569
for (final item in items) {
570
if (item is! Map) continue;
571
final modality = item['architecture']?['modality'] as String?;
571
- final isTextModel = modality == null ||
572
+ final isTextModel =
573
+ modality == null ||
574
modality.isEmpty ||
575
!modality.contains('->') ||
576
modality.split('->').last.contains('text');
@@ -606,10 +608,12 @@ class ServerApiService {
608
List<String>? supportedReasoningEfforts,
609
String? defaultReasoningEffort,
610
bool reasoningMandatory,
609
- }) _parseOpenRouterCapabilities(Map<dynamic, dynamic> item) {
611
+ })
612
+ _parseOpenRouterCapabilities(Map<dynamic, dynamic> item) {
613
final architecture = item['architecture'];
614
final arch = architecture is Map ? architecture : const <String, dynamic>{};
612
- final inputModalities = (arch['input_modalities'] as List?)
615
+ final inputModalities =
616
+ (arch['input_modalities'] as List?)
617
?.map((e) => e.toString().trim().toLowerCase())
618
.toSet() ??
619
<String>{};
@@ -617,11 +621,13 @@ class ServerApiService {
621
final supportsVision =
622
inputModalities.contains('image') || modality.contains('image');
623
620
- final supportedParams = (item['supported_parameters'] as List?)
624
+ final supportedParams =
625
+ (item['supported_parameters'] as List?)
626
?.map((e) => e.toString().trim().toLowerCase())
627
.toSet() ??
628
<String>{};
624
- final supportsToolUse = supportedParams.contains('tools') ||
629
+ final supportsToolUse =
630
+ supportedParams.contains('tools') ||
631
supportedParams.contains('tool_choice');
632
633
final reasoningField = item['reasoning'];
@@ -629,15 +635,19 @@ class ServerApiService {
635
final supportedEffortsRaw = reasoning?['supported_efforts'];
636
final supportedReasoningEfforts = supportedEffortsRaw is List
637
? supportedEffortsRaw
632
- .map((e) => e.toString().trim().toLowerCase())
633
- .where((e) => e.isNotEmpty)
634
- .toList(growable: false)
638
+ .map((e) => e.toString().trim().toLowerCase())
639
+ .where((e) => e.isNotEmpty)
640
+ .toList(growable: false)
641
: null;
636
- final defaultReasoningEffort =
637
- reasoning?['default_effort']?.toString().trim().toLowerCase();
642
+ final defaultReasoningEffort = reasoning?['default_effort']
643
+ ?.toString()
644
+ .trim()
645
+ .toLowerCase();
646
final reasoningMandatory = reasoning?['mandatory'] == true;
639
- final supportsReasoning = reasoning != null && reasoning.isNotEmpty ||
640
- supportedReasoningEfforts != null && supportedReasoningEfforts.isNotEmpty ||
647
+ final supportsReasoning =
648
+ reasoning != null && reasoning.isNotEmpty ||
649
+ supportedReasoningEfforts != null &&
650
+ supportedReasoningEfforts.isNotEmpty ||
651
reasoningMandatory ||
652
supportedParams.contains('reasoning') ||
653
supportedParams.contains('include_reasoning');
@@ -656,7 +666,7 @@ class ServerApiService {
666
/// quotes prices per token (e.g. `0.0000025`), so the value is scaled by
667
/// 1,000,000 for display-friendly numbers.
668
({double? inputPricePerMillion, double? outputPricePerMillion})
659
- _parseOpenRouterPricing(dynamic raw) {
669
+ _parseOpenRouterPricing(dynamic raw) {
670
if (raw is! Map) {
671
return (inputPricePerMillion: null, outputPricePerMillion: null);
672
}
@@ -687,9 +697,7 @@ class ServerApiService {
697
double? _parseParameterSize(String size) {
698
final cleaned = size.trim();
699
if (cleaned.endsWith('B') || cleaned.endsWith('b')) {
690
- return double.tryParse(
691
- cleaned.substring(0, cleaned.length - 1).trim(),
692
- );
700
+ return double.tryParse(cleaned.substring(0, cleaned.length - 1).trim());
701
}
702
if (cleaned.endsWith('M') || cleaned.endsWith('m')) {
703
final val = double.tryParse(
@@ -728,7 +736,8 @@ class ServerApiService {
736
if (map is! Map) return;
737
738
final hasError = map.containsKey('error') && map['error'] != null;
731
- final isFailedStatus = map['status'] == 'error' ||
739
+ final isFailedStatus =
740
+ map['status'] == 'error' ||
741
map['status'] == 'failed' ||
742
map['success'] == false;
743
@@ -764,8 +773,11 @@ class ServerApiService {
773
final code = error['code']?.toString();
774
if (message != null && message.trim().isNotEmpty) {
775
if (type != null && type.trim().isNotEmpty) return '$type: $message';
767
- if (code != null && code.trim().isNotEmpty) return 'Error $code: $message';
768
- return message;
776
+ if (code != null && code.trim().isNotEmpty) {
777
+ return 'Error $code: $message';
778
+ } else {
779
+ return message;
780
+ }
781
}
782
return type ?? code;
783
} else if (error is String && error.trim().isNotEmpty) {
lib/features/servers/views/add_server_screen.dart
+28
-14
@@ -133,8 +133,10 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
133
if (!mounted) return;
134
setState(() {
135
_testResult = isConnected
136
- ? AppLocalizations.of(context)?.connection_successful ?? 'Connection successful'
137
- : AppLocalizations.of(context)?.connection_failed ?? 'Connection failed';
136
+ ? AppLocalizations.of(context)?.connection_successful ??
137
+ 'Connection successful'
138
+ : AppLocalizations.of(context)?.connection_failed ??
139
+ 'Connection failed';
140
});
141
if (isConnected) {
142
invalidateAvailableModelsCache(testServer.id);
@@ -142,9 +144,9 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
144
} catch (e) {
145
if (!mounted) return;
146
setState(() {
145
- _testResult = AppLocalizations.of(
146
- context,
147
- )?.error_with_message(e.toString()) ?? 'Error: $e';
147
+ _testResult =
148
+ AppLocalizations.of(context)?.error_with_message(e.toString()) ??
149
+ 'Error: $e';
150
});
151
} finally {
152
if (mounted) {
@@ -236,7 +238,9 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
238
if (l10n != null) {
239
ScaffoldMessenger.of(context).showSnackBar(
240
SnackBar(
239
- content: Text(_isEditing ? l10n.server_updated : l10n.server_added),
241
+ content: Text(
242
+ _isEditing ? l10n.server_updated : l10n.server_added,
243
+ ),
244
duration: const Duration(seconds: 2),
245
),
246
);
@@ -438,8 +442,8 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
442
],
443
),
444
),
441
- HugeIcon(icon:
442
- HugeIcons.strokeRoundedArrowRight01,
445
+ HugeIcon(
446
+ icon: HugeIcons.strokeRoundedArrowRight01,
447
size: 16,
448
color: colorScheme.onSurfaceVariant,
449
),
@@ -559,10 +563,14 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
563
Container(
564
padding: const EdgeInsets.all(12),
565
decoration: BoxDecoration(
562
- color: theme.colorScheme.primary.withValues(alpha: 0.08),
566
+ color: theme.colorScheme.primary.withValues(
567
+ alpha: 0.08,
568
+ ),
569
borderRadius: BorderRadius.circular(8),
570
border: Border.all(
565
- color: theme.colorScheme.primary.withValues(alpha: 0.2),
571
+ color: theme.colorScheme.primary.withValues(
572
+ alpha: 0.2,
573
+ ),
574
),
575
),
576
child: Row(
@@ -580,7 +588,9 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
588
? l10n.ollama_cloud_disclosure
589
: l10n.openrouter_disclosure,
590
style: theme.textTheme.bodySmall?.copyWith(
583
- color: theme.colorScheme.onSurface.withValues(alpha: 0.8),
591
+ color: theme.colorScheme.onSurface.withValues(
592
+ alpha: 0.8,
593
+ ),
594
height: 1.4,
595
),
596
),
@@ -657,7 +667,9 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
667
),
668
_buildInfoPill(
669
context,
660
- icon: _requiresMandatoryApiKey ? HugeIcons.strokeRoundedKey01 : HugeIcons.strokeRoundedDatabase,
670
+ icon: _requiresMandatoryApiKey
671
+ ? HugeIcons.strokeRoundedKey01
672
+ : HugeIcons.strokeRoundedDatabase,
673
label: _requiresMandatoryApiKey
674
? l10n.api_key_required
675
: l10n.host_label,
@@ -727,8 +739,10 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
739
child: Row(
740
crossAxisAlignment: CrossAxisAlignment.start,
741
children: [
730
- HugeIcon(icon:
731
- success ? HugeIcons.strokeRoundedCheckmarkCircle01 : HugeIcons.strokeRoundedAlertCircle,
742
+ HugeIcon(
743
+ icon: success
744
+ ? HugeIcons.strokeRoundedCheckmarkCircle01
745
+ : HugeIcons.strokeRoundedAlertCircle,
746
color: accent,
747
),
748
const SizedBox(width: 10),
lib/features/servers/views/components/https_scheme_hint.dart
+4
-7
@@ -7,10 +7,7 @@ import '../../data/models/server.dart';
7
class HttpsSchemeHint extends StatelessWidget {
8
final TextEditingController controller;
9
10
- const HttpsSchemeHint({
11
- super.key,
12
- required this.controller,
13
- });
10
+ const HttpsSchemeHint({super.key, required this.controller});
11
12
@override
13
Widget build(BuildContext context) {
@@ -42,8 +39,8 @@ class HttpsSchemeHint extends StatelessWidget {
39
child: Row(
40
crossAxisAlignment: CrossAxisAlignment.start,
41
children: [
45
- HugeIcon(icon:
46
- HugeIcons.strokeRoundedLock,
42
+ HugeIcon(
43
+ icon: HugeIcons.strokeRoundedLock,
44
size: 18,
45
color: colorScheme.tertiary,
46
),
@@ -79,4 +76,4 @@ class HttpsSchemeHint extends StatelessWidget {
76
},
77
);
78
}
82
-}
\ No newline at end of file
79
+}
lib/features/servers/views/components/server_card.dart
+2
-5
@@ -411,10 +411,7 @@ class ServerCard extends StatelessWidget {
411
color: Colors.red,
412
),
413
const SizedBox(width: 8),
414
- Text(
415
- l10n.delete,
416
- style: const TextStyle(color: Colors.red),
417
- ),
414
+ Text(l10n.delete, style: const TextStyle(color: Colors.red)),
415
],
416
),
417
),
@@ -422,4 +419,4 @@ class ServerCard extends StatelessWidget {
419
),
420
);
421
}
425
-}
\ No newline at end of file
422
+}
lib/features/servers/views/components/server_icon_picker.dart
+4
-1
@@ -131,7 +131,10 @@ class _ServerIconPickerState extends State<ServerIconPicker> {
131
placeholder: Text(l10n.search_icons_hint),
132
leading: const Padding(
133
padding: EdgeInsetsDirectional.only(start: 8),
134
- child: HugeIcon(icon: HugeIcons.strokeRoundedSearch01, size: 18),
134
+ child: HugeIcon(
135
+ icon: HugeIcons.strokeRoundedSearch01,
136
+ size: 18,
137
+ ),
138
),
139
onChanged: _filterIcons,
140
),
lib/features/servers/views/components/server_picker_sheet.dart
+16
-10
@@ -22,9 +22,7 @@ void showServerPickerSheet(BuildContext context, WidgetRef ref) {
22
isScrollControlled: true,
23
backgroundColor: Colors.transparent,
24
builder: (context) => Container(
25
- padding: EdgeInsets.only(
26
- bottom: MediaQuery.of(context).padding.bottom,
27
- ),
25
+ padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom),
26
decoration: BoxDecoration(
27
color: theme.colorScheme.surface,
28
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
@@ -69,7 +67,10 @@ void showServerPickerSheet(BuildContext context, WidgetRef ref) {
67
),
68
IconButton(
69
onPressed: () => Navigator.pop(context),
72
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedCancel01, size: 20),
70
+ icon: const HugeIcon(
71
+ icon: HugeIcons.strokeRoundedCancel01,
72
+ size: 20,
73
+ ),
74
visualDensity: VisualDensity.compact,
75
color: colorScheme.onSurfaceVariant,
76
),
@@ -136,7 +137,8 @@ void showServerPickerSheet(BuildContext context, WidgetRef ref) {
137
child: Row(
138
children: [
139
HugeIcon(
139
- icon: currentServerIcon ??
140
+ icon:
141
+ currentServerIcon ??
142
HugeIcons.strokeRoundedServerStack01,
143
size: 20,
144
color: isCurrentlyActive
@@ -176,14 +178,15 @@ void showServerPickerSheet(BuildContext context, WidgetRef ref) {
178
),
179
),
180
if (isCurrentlyActive)
179
- HugeIcon(icon:
180
- HugeIcons.strokeRoundedCheckmarkCircle01,
181
+ HugeIcon(
182
+ icon:
183
+ HugeIcons.strokeRoundedCheckmarkCircle01,
184
size: 20,
185
color: colorScheme.primary,
186
)
187
else
185
- const HugeIcon(icon:
186
- HugeIcons.strokeRoundedArrowRight01,
188
+ const HugeIcon(
189
+ icon: HugeIcons.strokeRoundedArrowRight01,
190
size: 20,
191
color: Colors.grey,
192
),
@@ -198,7 +201,10 @@ void showServerPickerSheet(BuildContext context, WidgetRef ref) {
201
const SizedBox(height: 12),
202
ShadButton.ghost(
203
width: double.infinity,
201
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedSettings01, size: 18),
204
+ leading: const HugeIcon(
205
+ icon: HugeIcons.strokeRoundedSettings01,
206
+ size: 18,
207
+ ),
208
onPressed: () {
209
Navigator.pop(context);
210
context.go(AppRoutes.servers);
lib/features/servers/views/components/server_type_selector.dart
+2
-2
@@ -141,8 +141,8 @@ class ServerTypeSelector extends StatelessWidget {
141
PositionedDirectional(
142
top: 6,
143
end: 6,
144
- child: HugeIcon(icon:
145
- HugeIcons.strokeRoundedCheckmarkCircle01,
144
+ child: HugeIcon(
145
+ icon: HugeIcons.strokeRoundedCheckmarkCircle01,
146
size: 17,
147
color: accentColor,
148
),
lib/features/servers/views/server_list_screen.dart
+8
-4
@@ -148,8 +148,8 @@ class _ServerListScreenState extends ConsumerState<ServerListScreen> {
148
child: Column(
149
mainAxisAlignment: MainAxisAlignment.center,
150
children: [
151
- const HugeIcon(icon:
152
- HugeIcons.strokeRoundedInformationCircle,
151
+ const HugeIcon(
152
+ icon: HugeIcons.strokeRoundedInformationCircle,
153
color: Colors.red,
154
size: 48,
155
),
@@ -189,7 +189,11 @@ class _ServerListScreenState extends ConsumerState<ServerListScreen> {
189
child: Column(
190
mainAxisAlignment: MainAxisAlignment.center,
191
children: [
192
- HugeIcon(icon: HugeIcons.strokeRoundedComputer, size: 80, color: theme.colorScheme.outline),
192
+ HugeIcon(
193
+ icon: HugeIcons.strokeRoundedComputer,
194
+ size: 80,
195
+ color: theme.colorScheme.outline,
196
+ ),
197
const SizedBox(height: 24),
198
Text(l10n.no_servers_yet, style: theme.textTheme.headlineSmall),
199
const SizedBox(height: 8),
@@ -247,4 +251,4 @@ class _ServerListScreenState extends ConsumerState<ServerListScreen> {
251
},
252
);
253
}
250
-}
\ No newline at end of file
254
+}
lib/features/settings/views/data_backup_actions.dart
+40
-54
@@ -40,9 +40,9 @@ class DataBackupActions extends ConsumerWidget {
40
);
41
if (saved == null) return;
42
if (context.mounted) {
43
- ScaffoldMessenger.of(context).showSnackBar(
44
- SnackBar(content: Text(l10n.export_data_success)),
45
- );
43
+ ScaffoldMessenger.of(
44
+ context,
45
+ ).showSnackBar(SnackBar(content: Text(l10n.export_data_success)));
46
}
47
} catch (e) {
48
if (context.mounted) {
@@ -86,9 +86,9 @@ class DataBackupActions extends ConsumerWidget {
86
);
87
if (saved == null) return;
88
if (context.mounted) {
89
- ScaffoldMessenger.of(context).showSnackBar(
90
- SnackBar(content: Text(l10n.export_data_success)),
91
- );
89
+ ScaffoldMessenger.of(
90
+ context,
91
+ ).showSnackBar(SnackBar(content: Text(l10n.export_data_success)));
92
}
93
} catch (e) {
94
if (context.mounted) {
@@ -151,9 +151,9 @@ class DataBackupActions extends ConsumerWidget {
151
await importer(json);
152
153
if (context.mounted) {
154
- ScaffoldMessenger.of(context).showSnackBar(
155
- SnackBar(content: Text(l10n.import_data_success)),
156
- );
154
+ ScaffoldMessenger.of(
155
+ context,
156
+ ).showSnackBar(SnackBar(content: Text(l10n.import_data_success)));
157
}
158
} catch (e) {
159
if (context.mounted) {
@@ -207,8 +207,9 @@ class DataBackupActions extends ConsumerWidget {
207
final archive = ZipDecoder().decodeBytes(bytes);
208
for (final file in archive.files) {
209
if (!file.isFile || file.name != 'settings.json') continue;
210
- final decoded = jsonDecode(utf8.decode(file.content as List<int>))
211
- as Map<String, dynamic>;
210
+ final decoded =
211
+ jsonDecode(utf8.decode(file.content as List<int>))
212
+ as Map<String, dynamic>;
213
await _applySettingsPayload(ref, decoded);
214
}
215
@@ -222,9 +223,9 @@ class DataBackupActions extends ConsumerWidget {
223
});
224
225
if (context.mounted) {
225
- ScaffoldMessenger.of(context).showSnackBar(
226
- SnackBar(content: Text(l10n.import_data_success)),
227
- );
226
+ ScaffoldMessenger.of(
227
+ context,
228
+ ).showSnackBar(SnackBar(content: Text(l10n.import_data_success)));
229
}
230
} catch (e) {
231
if (context.mounted) {
@@ -259,15 +260,9 @@ class DataBackupActions extends ConsumerWidget {
260
style: const TextStyle(fontWeight: FontWeight.w600),
261
),
262
),
262
- ShadButton.outline(
263
- onPressed: onExport,
264
- child: Text(l10n.export),
265
- ),
263
+ ShadButton.outline(onPressed: onExport, child: Text(l10n.export)),
264
const SizedBox(width: 8),
267
- ShadButton.outline(
268
- onPressed: onImport,
269
- child: Text(l10n.import),
270
- ),
265
+ ShadButton.outline(onPressed: onImport, child: Text(l10n.import)),
266
],
267
),
268
);
@@ -292,16 +287,11 @@ class DataBackupActions extends ConsumerWidget {
287
'localmind_conversations_${DateTime.now().millisecondsSinceEpoch}.json',
288
service.exportConversationsAsJson(db.store),
289
),
295
- onImport: () => _importJsonFile(
296
- context,
297
- ref,
298
- (json) async {
299
- await service.importFromJson(db.store, json);
300
- ref.invalidate(conversationsProvider);
301
- ref.invalidate(savedMessagesProvider);
302
- },
303
- l10n.import_data_confirm,
304
- ),
290
+ onImport: () => _importJsonFile(context, ref, (json) async {
291
+ await service.importFromJson(db.store, json);
292
+ ref.invalidate(conversationsProvider);
293
+ ref.invalidate(savedMessagesProvider);
294
+ }, l10n.import_data_confirm),
295
),
296
_categoryRow(
297
label: l10n.personas_label,
@@ -312,15 +302,10 @@ class DataBackupActions extends ConsumerWidget {
302
'localmind_personas_${DateTime.now().millisecondsSinceEpoch}.json',
303
service.exportPersonasAsJson(db.store),
304
),
315
- onImport: () => _importJsonFile(
316
- context,
317
- ref,
318
- (json) async {
319
- await service.importFromJson(db.store, json);
320
- ref.invalidate(personasNotifierProvider);
321
- },
322
- l10n.import_data_confirm,
323
- ),
305
+ onImport: () => _importJsonFile(context, ref, (json) async {
306
+ await service.importFromJson(db.store, json);
307
+ ref.invalidate(personasNotifierProvider);
308
+ }, l10n.import_data_confirm),
309
),
310
_categoryRow(
311
label: l10n.settings_label,
@@ -335,16 +320,11 @@ class DataBackupActions extends ConsumerWidget {
320
prefs: prefs,
321
),
322
),
338
- onImport: () => _importJsonFile(
339
- context,
340
- ref,
341
- (json) async {
342
- final decoded = jsonDecode(json) as Map<String, dynamic>;
343
- await service.importFromJson(db.store, json);
344
- await _applySettingsPayload(ref, decoded);
345
- },
346
- l10n.import_settings_confirm,
347
- ),
323
+ onImport: () => _importJsonFile(context, ref, (json) async {
324
+ final decoded = jsonDecode(json) as Map<String, dynamic>;
325
+ await service.importFromJson(db.store, json);
326
+ await _applySettingsPayload(ref, decoded);
327
+ }, l10n.import_settings_confirm),
328
),
329
const SizedBox(height: 8),
330
ShadButton.outline(
@@ -355,7 +335,10 @@ class DataBackupActions extends ConsumerWidget {
335
'localmind_backup_${DateTime.now().millisecondsSinceEpoch}.zip',
336
),
337
width: double.infinity,
358
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedFolder01, size: 16),
338
+ leading: const HugeIcon(
339
+ icon: HugeIcons.strokeRoundedFolder01,
340
+ size: 16,
341
+ ),
342
child: Align(
343
alignment: Alignment.centerLeft,
344
child: Text(l10n.export_all_zip),
@@ -365,7 +348,10 @@ class DataBackupActions extends ConsumerWidget {
348
ShadButton.outline(
349
onPressed: () => _importZipFile(context, ref),
350
width: double.infinity,
368
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedArchive, size: 16),
351
+ leading: const HugeIcon(
352
+ icon: HugeIcons.strokeRoundedArchive,
353
+ size: 16,
354
+ ),
355
child: Align(
356
alignment: Alignment.centerLeft,
357
child: Text(l10n.import_all_zip),
@@ -374,4 +360,4 @@ class DataBackupActions extends ConsumerWidget {
360
],
361
);
362
}
377
-}
\ No newline at end of file
363
+}
lib/features/sidebar/components/active_server_indicator.dart
+25
-9
@@ -155,7 +155,10 @@ class ActiveServerIndicator extends ConsumerWidget {
155
),
156
IconButton(
157
onPressed: () => Navigator.pop(context),
158
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedCancel01, size: 20),
158
+ icon: const HugeIcon(
159
+ icon: HugeIcons.strokeRoundedCancel01,
160
+ size: 20,
161
+ ),
162
visualDensity: VisualDensity.compact,
163
color: colorScheme.onSurfaceVariant,
164
),
@@ -287,14 +290,16 @@ class ActiveServerIndicator extends ConsumerWidget {
290
),
291
),
292
if (isCurrentlyActive)
290
- HugeIcon(icon:
291
- HugeIcons.strokeRoundedCheckmarkCircle01,
293
+ HugeIcon(
294
+ icon: HugeIcons
295
+ .strokeRoundedCheckmarkCircle01,
296
size: 20,
297
color: colorScheme.primary,
298
)
299
else
296
- const HugeIcon(icon:
297
- HugeIcons.strokeRoundedArrowRight01,
300
+ const HugeIcon(
301
+ icon: HugeIcons
302
+ .strokeRoundedArrowRight01,
303
size: 20,
304
color: Colors.grey,
305
),
@@ -310,7 +315,10 @@ class ActiveServerIndicator extends ConsumerWidget {
315
const SizedBox(height: 12),
316
ShadButton.ghost(
317
width: double.infinity,
313
- leading: const HugeIcon(icon: HugeIcons.strokeRoundedSettings01, size: 18),
318
+ leading: const HugeIcon(
319
+ icon: HugeIcons.strokeRoundedSettings01,
320
+ size: 18,
321
+ ),
322
onPressed: () {
323
Navigator.pop(context);
324
scaffold?.closeDrawer();
@@ -347,7 +355,9 @@ class ActiveServerIndicator extends ConsumerWidget {
355
HugeIcon(
356
icon: serverIcon ?? HugeIcons.strokeRoundedServerStack01,
357
size: 18,
350
- color: isDark ? AppColors.darkPrimaryText : AppColors.lightPrimaryText,
358
+ color: isDark
359
+ ? AppColors.darkPrimaryText
360
+ : AppColors.lightPrimaryText,
361
),
362
const SizedBox(width: 10),
363
Expanded(
@@ -359,7 +369,9 @@ class ActiveServerIndicator extends ConsumerWidget {
369
activeServer.name,
370
style: theme.textTheme.bodySmall?.copyWith(
371
fontWeight: FontWeight.w600,
362
- color: isDark ? AppColors.darkPrimaryText : AppColors.lightPrimaryText,
372
+ color: isDark
373
+ ? AppColors.darkPrimaryText
374
+ : AppColors.lightPrimaryText,
375
),
376
overflow: TextOverflow.ellipsis,
377
maxLines: 1,
@@ -388,7 +400,11 @@ class ActiveServerIndicator extends ConsumerWidget {
400
],
401
),
402
),
391
- const HugeIcon(icon: HugeIcons.strokeRoundedArrowUpDown, size: 16, color: Colors.grey),
403
+ const HugeIcon(
404
+ icon: HugeIcons.strokeRoundedArrowUpDown,
405
+ size: 16,
406
+ color: Colors.grey,
407
+ ),
408
],
409
),
410
),
lib/features/sidebar/components/conversation_drawer_header.dart
+3
-1
@@ -32,7 +32,9 @@ class ConversationDrawerHeader extends ConsumerWidget {
32
style: TextStyle(
33
fontSize: 18,
34
fontWeight: FontWeight.bold,
35
- color: isDark ? AppColors.darkPrimaryText : AppColors.lightPrimaryText,
35
+ color: isDark
36
+ ? AppColors.darkPrimaryText
37
+ : AppColors.lightPrimaryText,
38
),
39
),
40
],
lib/features/sidebar/components/drawer_nav_item.dart
+12
-10
@@ -34,9 +34,7 @@ class DrawerNavItem extends StatelessWidget {
34
child: Container(
35
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
36
decoration: BoxDecoration(
37
- color: isSelected
38
- ? accent.withAlpha(30)
39
- : Colors.transparent,
37
+ color: isSelected ? accent.withAlpha(30) : Colors.transparent,
38
borderRadius: BorderRadius.circular(8),
39
),
40
child: Row(
@@ -46,7 +44,9 @@ class DrawerNavItem extends StatelessWidget {
44
size: 20,
45
color: isSelected
46
? accent
49
- : (isDark ? AppColors.darkPrimaryText : AppColors.lightPrimaryText),
47
+ : (isDark
48
+ ? AppColors.darkPrimaryText
49
+ : AppColors.lightPrimaryText),
50
),
51
const SizedBox(width: 12),
52
Expanded(
@@ -57,7 +57,9 @@ class DrawerNavItem extends StatelessWidget {
57
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
58
color: isSelected
59
? accent
60
- : (isDark ? AppColors.darkPrimaryText : AppColors.lightPrimaryText),
60
+ : (isDark
61
+ ? AppColors.darkPrimaryText
62
+ : AppColors.lightPrimaryText),
63
),
64
overflow: TextOverflow.ellipsis,
65
),
@@ -65,7 +67,10 @@ class DrawerNavItem extends StatelessWidget {
67
if (badgeText != null && badgeText!.isNotEmpty) ...[
68
const SizedBox(width: 8),
69
Container(
68
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
70
+ padding: const EdgeInsets.symmetric(
71
+ horizontal: 8,
72
+ vertical: 2,
73
+ ),
74
decoration: BoxDecoration(
75
color: accent.withAlpha(45),
76
borderRadius: BorderRadius.circular(999),
@@ -83,10 +88,7 @@ class DrawerNavItem extends StatelessWidget {
88
),
89
),
90
],
86
- if (trailing != null) ...[
87
- const SizedBox(width: 8),
88
- trailing!,
89
- ],
91
+ if (trailing != null) ...[const SizedBox(width: 8), trailing!],
92
],
93
),
94
),
lib/features/sidebar/components/github_repo_card.dart
+14
-6
@@ -42,7 +42,9 @@ class GitHubRepoCard extends StatelessWidget {
42
HugeIcon(
43
icon: HugeIcons.strokeRoundedGithub,
44
size: 20,
45
- color: isDark ? AppColors.darkPrimaryText : AppColors.lightPrimaryText,
45
+ color: isDark
46
+ ? AppColors.darkPrimaryText
47
+ : AppColors.lightPrimaryText,
48
),
49
const SizedBox(width: 10),
50
Expanded(
@@ -55,7 +57,9 @@ class GitHubRepoCard extends StatelessWidget {
57
style: TextStyle(
58
fontSize: 13,
59
fontWeight: FontWeight.w600,
58
- color: isDark ? AppColors.darkPrimaryText : AppColors.lightPrimaryText,
60
+ color: isDark
61
+ ? AppColors.darkPrimaryText
62
+ : AppColors.lightPrimaryText,
63
),
64
),
65
const SizedBox(height: 2),
@@ -64,16 +68,20 @@ class GitHubRepoCard extends StatelessWidget {
68
style: TextStyle(
69
fontSize: 11,
70
fontWeight: FontWeight.w500,
67
- color: isDark ? AppColors.darkAccent : AppColors.lightAccent,
71
+ color: isDark
72
+ ? AppColors.darkAccent
73
+ : AppColors.lightAccent,
74
),
75
),
76
],
77
),
78
),
73
- HugeIcon(icon:
74
- HugeIcons.strokeRoundedShare01,
79
+ HugeIcon(
80
+ icon: HugeIcons.strokeRoundedShare01,
81
size: 14,
76
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
82
+ color: isDark
83
+ ? AppColors.darkMutedText
84
+ : AppColors.lightMutedText,
85
),
86
],
87
),
lib/features/sidebar/components/sidebar_search_button.dart
+10
-6
@@ -29,9 +29,7 @@ class SidebarSearchButton extends ConsumerWidget {
29
child: Container(
30
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
31
decoration: BoxDecoration(
32
- color: isDark
33
- ? AppColors.darkSurfaceInput
34
- : AppColors.lightSurface,
32
+ color: isDark ? AppColors.darkSurfaceInput : AppColors.lightSurface,
33
borderRadius: BorderRadius.circular(8),
34
border: Border.all(
35
color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
@@ -42,7 +40,9 @@ class SidebarSearchButton extends ConsumerWidget {
40
HugeIcon(
41
icon: HugeIcons.strokeRoundedSearch01,
42
size: 18,
45
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
43
+ color: isDark
44
+ ? AppColors.darkMutedText
45
+ : AppColors.lightMutedText,
46
),
47
const SizedBox(width: 10),
48
Expanded(
@@ -50,7 +50,9 @@ class SidebarSearchButton extends ConsumerWidget {
50
'Search conversations...',
51
style: TextStyle(
52
fontSize: 13,
53
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
53
+ color: isDark
54
+ ? AppColors.darkMutedText
55
+ : AppColors.lightMutedText,
56
),
57
overflow: TextOverflow.ellipsis,
58
),
@@ -60,7 +62,9 @@ class SidebarSearchButton extends ConsumerWidget {
62
style: TextStyle(
63
fontSize: 11,
64
fontWeight: FontWeight.w500,
63
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
65
+ color: isDark
66
+ ? AppColors.darkMutedText
67
+ : AppColors.lightMutedText,
68
letterSpacing: 0.5,
69
),
70
),
lib/features/sidebar/components/theme_toggle_button.dart
+6
-8
@@ -15,7 +15,8 @@ class ThemeToggleButton extends ConsumerWidget {
15
return ShadIconButton.ghost(
16
onPressed: () {
17
// Cycle through: System -> Light -> Dark -> Claude -> System
18
- final nextMode = AppThemeType.values[(themeMode.index + 1) % AppThemeType.values.length];
18
+ final nextMode = AppThemeType
19
+ .values[(themeMode.index + 1) % AppThemeType.values.length];
20
ref.read(themeModeProvider.notifier).setThemeMode(nextMode);
21
},
22
icon: AnimatedSwitcher(
@@ -23,14 +24,11 @@ class ThemeToggleButton extends ConsumerWidget {
24
transitionBuilder: (child, animation) {
25
return RotationTransition(
26
turns: animation,
26
- child: FadeTransition(
27
- opacity: animation,
28
- child: child,
29
- ),
27
+ child: FadeTransition(opacity: animation, child: child),
28
);
29
},
32
- child: HugeIcon(icon:
33
- _getIconForMode(themeMode),
30
+ child: HugeIcon(
31
+ icon: _getIconForMode(themeMode),
32
key: ValueKey(themeMode),
33
size: 20,
34
),
@@ -50,4 +48,4 @@ class ThemeToggleButton extends ConsumerWidget {
48
return HugeIcons.strokeRoundedTree01;
49
}
50
}
53
-}
\ No newline at end of file
51
+}
lib/features/sidebar/sidebar_widget.dart
+4
-2
@@ -67,8 +67,10 @@ class SidebarWidget extends ConsumerWidget {
67
delay: 60.ms,
68
acts: [.fadeIn(), .slideX(from: -0.04)],
69
child: Padding(
70
- padding:
71
- const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
70
+ padding: const EdgeInsets.symmetric(
71
+ horizontal: 16,
72
+ vertical: 8,
73
+ ),
74
child: ShadButton(
75
width: double.infinity,
76
leading: const HugeIcon(
lib/features/tts/data/kitten_tts_downloader.dart
+3
-1
@@ -165,6 +165,8 @@ class KittenTtsDownloader {
165
}
166
167
Future<Directory> getDataDir(KittenTtsModelVariant variant) async {
168
- return Directory('${(await _getVariantDir(variant)).path}/${variant.dataDirName}');
168
+ return Directory(
169
+ '${(await _getVariantDir(variant)).path}/${variant.dataDirName}',
170
+ );
171
}
172
}
lib/features/tts/data/kitten_tts_model.dart
+6
-6
@@ -136,12 +136,12 @@ enum KittenTtsModelVariant {
136
List<KittenTtsModelFile> get files {
137
return [
138
KittenTtsModelFile(
139
- fileName: 'config.json',
140
- downloadUrl:
141
- 'https://huggingface.co/$huggingFaceRepoId/resolve/main/config.json',
142
- sizeBytes: 2 * 1024,
143
- ),
144
- KittenTtsModelFile(
139
+ fileName: 'config.json',
140
+ downloadUrl:
141
+ 'https://huggingface.co/$huggingFaceRepoId/resolve/main/config.json',
142
+ sizeBytes: 2 * 1024,
143
+ ),
144
+ KittenTtsModelFile(
145
fileName: sourceModelFileName,
146
downloadUrl:
147
'https://huggingface.co/$huggingFaceRepoId/resolve/main/$sourceModelFileName',
lib/features/tts/data/piper_tts_downloader.dart
+7
-5
@@ -14,9 +14,7 @@ class PiperTtsDownloader {
14
final ModelDownloader _baseDownloader;
15
final Map<PiperTtsModelVariant, CancelToken> _cancelTokens = {};
16
17
- PiperTtsDownloader()
18
- : _dio = Dio(),
19
- _baseDownloader = ModelDownloader();
17
+ PiperTtsDownloader() : _dio = Dio(), _baseDownloader = ModelDownloader();
18
19
Future<Directory> _getEngineDir() async {
20
final ttsDir = await _baseDownloader.getTtsDir();
@@ -39,7 +37,9 @@ class PiperTtsDownloader {
37
final modelFile = File('${dir.path}/${variant.modelFileName}');
38
final tokensFile = File('${dir.path}/${variant.tokensFileName}');
39
final dataDir = Directory('${dir.path}/${variant.dataDirName}');
42
- if (await modelFile.exists() && await tokensFile.exists() && await dataDir.exists()) {
40
+ if (await modelFile.exists() &&
41
+ await tokensFile.exists() &&
42
+ await dataDir.exists()) {
43
downloaded.add(variant);
44
}
45
}
@@ -154,6 +154,8 @@ class PiperTtsDownloader {
154
}
155
156
Future<Directory> getDataDir(PiperTtsModelVariant variant) async {
157
- return Directory('${(await _getVariantDir(variant)).path}/${variant.dataDirName}');
157
+ return Directory(
158
+ '${(await _getVariantDir(variant)).path}/${variant.dataDirName}',
159
+ );
160
}
161
}
lib/features/tts/providers/tts_model_providers.dart
+14
-2
@@ -88,6 +88,7 @@ class TtsDownloadNotifier
88
.downloadModel(model)
89
.listen(
90
(progress) {
91
+ if (!ref.mounted) return;
92
final variantMap = Map<String, KittenTtsFileProgress>.from(
93
state[variant] ?? {},
94
);
@@ -96,7 +97,9 @@ class TtsDownloadNotifier
97
},
98
onDone: () {
99
_subscriptions.remove(variant);
99
- ref.invalidate(downloadedKittenTtsVariantsProvider);
100
+ if (ref.mounted) {
101
+ ref.invalidate(downloadedKittenTtsVariantsProvider);
102
+ }
103
if (!completer.isCompleted) completer.complete();
104
},
105
onError: (e) {
@@ -112,6 +115,7 @@ class TtsDownloadNotifier
115
116
/// Cancel an in-flight download.
117
void cancelDownload(KittenTtsModelVariant variant) {
118
+ if (!ref.mounted) return;
119
ref.read(kittenTtsDownloaderProvider).cancelDownload(variant);
120
_subscriptions[variant]?.cancel();
121
_subscriptions.remove(variant);
@@ -126,8 +130,10 @@ class TtsDownloadNotifier
130
131
/// Delete a downloaded variant.
132
Future<void> deleteVariant(KittenTtsModelVariant variant) async {
133
+ if (!ref.mounted) return;
134
cancelDownload(variant);
135
await ref.read(kittenTtsDownloaderProvider).deleteVariant(variant);
136
+ if (!ref.mounted) return;
137
138
final newState =
139
Map<KittenTtsModelVariant, Map<String, KittenTtsFileProgress>>.from(
@@ -201,6 +207,7 @@ class PiperTtsDownloadNotifier
207
.downloadModel(variant)
208
.listen(
209
(progress) {
210
+ if (!ref.mounted) return;
211
final variantMap = Map<String, PiperTtsFileProgress>.from(
212
state[variant] ?? {},
213
);
@@ -210,7 +217,9 @@ class PiperTtsDownloadNotifier
217
onDone: () {
218
_subscriptions.remove(variant);
219
_completers.remove(variant);
213
- ref.invalidate(downloadedPiperTtsVariantsProvider);
220
+ if (ref.mounted) {
221
+ ref.invalidate(downloadedPiperTtsVariantsProvider);
222
+ }
223
if (!completer.isCompleted) completer.complete();
224
},
225
onError: (e) {
@@ -226,6 +235,7 @@ class PiperTtsDownloadNotifier
235
}
236
237
void cancelDownload(PiperTtsModelVariant variant) {
238
+ if (!ref.mounted) return;
239
ref.read(piperTtsDownloaderProvider).cancelDownload(variant);
240
_subscriptions[variant]?.cancel();
241
_subscriptions.remove(variant);
@@ -243,8 +253,10 @@ class PiperTtsDownloadNotifier
253
}
254
255
Future<void> deleteVariant(PiperTtsModelVariant variant) async {
256
+ if (!ref.mounted) return;
257
cancelDownload(variant);
258
await ref.read(piperTtsDownloaderProvider).deleteVariant(variant);
259
+ if (!ref.mounted) return;
260
261
final newState =
262
Map<PiperTtsModelVariant, Map<String, PiperTtsFileProgress>>.from(
lib/features/tts/providers/tts_providers.dart
+34
-37
@@ -400,43 +400,42 @@ class TtsNotifier extends Notifier<TtsState> {
400
401
void _startSystemProgressTimer(int speakGeneration) {
402
_stopSystemProgressTimer();
403
- _systemProgressTimer = Timer.periodic(
404
- const Duration(milliseconds: 80),
405
- (timer) {
406
- if (_isStopping ||
407
- speakGeneration != _systemSpeakGeneration ||
408
- !state.isSpeaking ||
409
- state.activeEngine != EngineId.system) {
410
- timer.cancel();
411
- if (identical(_systemProgressTimer, timer)) {
412
- _systemProgressTimer = null;
413
- }
414
- return;
415
- }
416
-
417
- final duration = _systemEstimatedDuration();
418
- final position = _systemEstimatedPosition();
419
- final text = _systemFullText ?? '';
420
- int? estimatedCharOffset;
421
- if (!_systemHasNativeProgress && text.isNotEmpty) {
422
- final durationMicros = duration.inMicroseconds;
423
- final progress = durationMicros <= 0
424
- ? 0.0
425
- : position.inMicroseconds / durationMicros;
426
- estimatedCharOffset = (progress * text.length).floor().clamp(
427
- 0,
428
- text.length - 1,
429
- );
403
+ _systemProgressTimer = Timer.periodic(const Duration(milliseconds: 80), (
404
+ timer,
405
+ ) {
406
+ if (_isStopping ||
407
+ speakGeneration != _systemSpeakGeneration ||
408
+ !state.isSpeaking ||
409
+ state.activeEngine != EngineId.system) {
410
+ timer.cancel();
411
+ if (identical(_systemProgressTimer, timer)) {
412
+ _systemProgressTimer = null;
413
}
414
+ return;
415
+ }
416
432
- state = state.copyWith(
433
- position: position,
434
- duration: duration,
435
- spokenCharOffset: estimatedCharOffset,
436
- playingContent: state.playingContent,
417
+ final duration = _systemEstimatedDuration();
418
+ final position = _systemEstimatedPosition();
419
+ final text = _systemFullText ?? '';
420
+ int? estimatedCharOffset;
421
+ if (!_systemHasNativeProgress && text.isNotEmpty) {
422
+ final durationMicros = duration.inMicroseconds;
423
+ final progress = durationMicros <= 0
424
+ ? 0.0
425
+ : position.inMicroseconds / durationMicros;
426
+ estimatedCharOffset = (progress * text.length).floor().clamp(
427
+ 0,
428
+ text.length - 1,
429
);
438
- },
439
- );
430
+ }
431
+
432
+ state = state.copyWith(
433
+ position: position,
434
+ duration: duration,
435
+ spokenCharOffset: estimatedCharOffset,
436
+ playingContent: state.playingContent,
437
+ );
438
+ });
439
}
440
441
void _resetSystemSeekState() {
@@ -881,9 +880,7 @@ class TtsNotifier extends Notifier<TtsState> {
880
id: 'tts_chunk_${_currentSessionId}_$chunkIndex',
881
album: _isPreview ? 'Voice Preview' : 'LocalMind TTS',
882
title: _chunks[chunkIndex],
884
- artist: _currentEngine == EngineId.kitten
885
- ? 'Kitten TTS'
886
- : 'Piper TTS',
883
+ artist: _currentEngine == EngineId.kitten ? 'Kitten TTS' : 'Piper TTS',
884
),
885
);
886
lib/features/tts/providers/tts_worker.dart
+25
-16
@@ -84,11 +84,13 @@ void ttsWorkerEntry(SendPort mainSendPort) {
84
mainSendPort.send(TtsWorkerResponse(isInitResponse: true));
85
} else if (message is TtsSynthesizeRequest) {
86
if (activeEngineId == null || tts == null || activeInit == null) {
87
- mainSendPort.send(TtsWorkerResponse(
88
- chunkIndex: message.chunkIndex,
89
- sessionId: message.sessionId,
90
- error: 'No engine initialized in worker',
91
- ));
87
+ mainSendPort.send(
88
+ TtsWorkerResponse(
89
+ chunkIndex: message.chunkIndex,
90
+ sessionId: message.sessionId,
91
+ error: 'No engine initialized in worker',
92
+ ),
93
+ );
94
return;
95
}
96
@@ -99,22 +101,29 @@ void ttsWorkerEntry(SendPort mainSendPort) {
101
silenceScale: 0.2,
102
);
103
102
- final audio = tts!.generateWithConfig(text: message.text, config: genConfig);
104
+ final audio = tts!.generateWithConfig(
105
+ text: message.text,
106
+ config: genConfig,
107
+ );
108
if (audio.samples.isEmpty) {
104
- mainSendPort.send(TtsWorkerResponse(
105
- chunkIndex: message.chunkIndex,
106
- sessionId: message.sessionId,
107
- error: 'Empty PCM generated',
108
- ));
109
+ mainSendPort.send(
110
+ TtsWorkerResponse(
111
+ chunkIndex: message.chunkIndex,
112
+ sessionId: message.sessionId,
113
+ error: 'Empty PCM generated',
114
+ ),
115
+ );
116
return;
117
}
118
119
final wavBytes = _createWav(audio.samples, audio.sampleRate);
113
- mainSendPort.send(TtsWorkerResponse(
114
- chunkIndex: message.chunkIndex,
115
- sessionId: message.sessionId,
116
- wavBytes: wavBytes,
117
- ));
120
+ mainSendPort.send(
121
+ TtsWorkerResponse(
122
+ chunkIndex: message.chunkIndex,
123
+ sessionId: message.sessionId,
124
+ wavBytes: wavBytes,
125
+ ),
126
+ );
127
} else if (message is TtsStopRequest) {
128
// No-op: the main isolate handles queue cancellation.
129
}
lib/features/tts/utils/tts_text_processor.dart
+4
-1
@@ -23,7 +23,10 @@ class TtsTextProcessor {
23
(m) => m.group(1)?.isNotEmpty == true ? m.group(1)! : ' ',
24
);
25
// Links [text](url)
26
- s = s.replaceAllMapped(RegExp(r'\[([^\]]+)\]\([^)]*\)'), (m) => m.group(1)!);
26
+ s = s.replaceAllMapped(
27
+ RegExp(r'\[([^\]]+)\]\([^)]*\)'),
28
+ (m) => m.group(1)!,
29
+ );
30
// Bold / italic
31
s = s.replaceAllMapped(RegExp(r'\*\*\*(.+?)\*\*\*'), (m) => m.group(1)!);
32
s = s.replaceAllMapped(RegExp(r'\*\*(.+?)\*\*'), (m) => m.group(1)!);
lib/features/tts/views/components/tts_player_bar.dart
+32
-21
@@ -108,19 +108,21 @@ class TtsPlayerBar extends ConsumerWidget {
108
final preview = _truncateContent(ttsState.playingContent);
109
final canJumpToMessage = ttsState.playingMessageId != null;
110
final skipSeconds = ref.watch(settingsProvider).ttsSkipSeconds;
111
- final settingsEngine =
112
- ref.watch(settingsProvider.select((s) => s.ttsEngine));
113
- final isSystemTts = ttsState.activeEngine == EngineId.system ||
111
+ final settingsEngine = ref.watch(
112
+ settingsProvider.select((s) => s.ttsEngine),
113
+ );
114
+ final isSystemTts =
115
+ ttsState.activeEngine == EngineId.system ||
116
settingsEngine == EngineId.system ||
117
(ttsState.activeEngine == null && settingsEngine == EngineId.system);
118
final showSeekControls = !isSystemTts;
119
final hasTimeline = showSeekControls && ttsState.duration > Duration.zero;
120
final progress = showSeekControls
121
? (ttsState.duration > Duration.zero
120
- ? (ttsState.position.inMilliseconds /
121
- ttsState.duration.inMilliseconds)
122
- .clamp(0.0, 1.0)
123
- : 0.0)
122
+ ? (ttsState.position.inMilliseconds /
123
+ ttsState.duration.inMilliseconds)
124
+ .clamp(0.0, 1.0)
125
+ : 0.0)
126
: null;
127
final speedLabel =
128
'${ttsState.playbackSpeed.toStringAsFixed(2).replaceAll(RegExp(r'\.?0+$'), '')}x';
@@ -158,11 +160,13 @@ class TtsPlayerBar extends ConsumerWidget {
160
child: Container(
161
padding: const EdgeInsets.all(6),
162
decoration: BoxDecoration(
161
- color: theme.colorScheme.secondary.withValues(alpha: 0.1),
163
+ color: theme.colorScheme.secondary.withValues(
164
+ alpha: 0.1,
165
+ ),
166
borderRadius: BorderRadius.circular(8),
167
),
164
- child: HugeIcon(icon:
165
- ttsState.isPaused
168
+ child: HugeIcon(
169
+ icon: ttsState.isPaused
170
? HugeIcons.strokeRoundedPlay
171
: HugeIcons.strokeRoundedPause,
172
size: 20,
@@ -242,7 +246,10 @@ class TtsPlayerBar extends ConsumerWidget {
246
if (!ttsState.isInitializing &&
247
ttsState.activeEngine != EngineId.system)
248
IconButton(
245
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedDownload01, size: 20),
249
+ icon: const HugeIcon(
250
+ icon: HugeIcons.strokeRoundedDownload01,
251
+ size: 20,
252
+ ),
253
tooltip: l10n.download_tts_audio,
254
visualDensity: VisualDensity.compact,
255
onPressed: () async {
@@ -285,8 +292,8 @@ class TtsPlayerBar extends ConsumerWidget {
292
color: theme.colorScheme.error.withValues(alpha: 0.1),
293
borderRadius: BorderRadius.circular(8),
294
),
288
- child: HugeIcon(icon:
289
- HugeIcons.strokeRoundedStop,
295
+ child: HugeIcon(
296
+ icon: HugeIcons.strokeRoundedStop,
297
size: 20,
298
color: theme.colorScheme.error,
299
),
@@ -408,8 +415,8 @@ class _TtsSeekSliderState extends State<_TtsSeekSlider> {
415
setState(() => _dragValue = null);
416
await widget.onSeek(
417
Duration(
411
- milliseconds:
412
- (v * widget.duration.inMilliseconds).round(),
418
+ milliseconds: (v * widget.duration.inMilliseconds)
419
+ .round(),
420
),
421
);
422
}
@@ -425,9 +432,9 @@ class _TtsSeekSliderState extends State<_TtsSeekSlider> {
432
widget.formatDuration(
433
_dragValue != null
434
? Duration(
428
- milliseconds: (_dragValue! *
429
- widget.duration.inMilliseconds)
430
- .round(),
435
+ milliseconds:
436
+ (_dragValue! * widget.duration.inMilliseconds)
437
+ .round(),
438
)
439
: widget.position,
440
),
@@ -522,10 +529,14 @@ class _WaveformIndicatorState extends State<_WaveformIndicator>
529
? 4.0 + 12.0 * (0.5 + 0.5 * math.sin(t * 2 * math.pi))
530
: 4.0;
531
final h2 = widget.isPlaying
525
- ? 4.0 + 12.0 * (0.5 + 0.5 * math.sin(t * 2 * math.pi + math.pi / 3))
532
+ ? 4.0 +
533
+ 12.0 * (0.5 + 0.5 * math.sin(t * 2 * math.pi + math.pi / 3))
534
: 6.0;
535
final h3 = widget.isPlaying
528
- ? 4.0 + 12.0 * (0.5 + 0.5 * math.sin(t * 2 * math.pi + 2 * math.pi / 3))
536
+ ? 4.0 +
537
+ 12.0 *
538
+ (0.5 +
539
+ 0.5 * math.sin(t * 2 * math.pi + 2 * math.pi / 3))
540
: 4.0;
541
542
return Row(
@@ -543,4 +554,4 @@ class _WaveformIndicatorState extends State<_WaveformIndicator>
554
),
555
);
556
}
546
-}
\ No newline at end of file
557
+}
lib/features/tts/views/tts_model_manager_screen.dart
+112
-62
@@ -35,51 +35,57 @@ class TtsModelManagerScreen extends ConsumerWidget {
35
top: topPadding + 8,
36
bottom: 16,
37
),
38
- decoration: BoxDecoration(
39
- color: isDark ? const Color(0xFF0A0A0A) : const Color(0xFFFAFAFA),
40
- border: Border(
41
- bottom: BorderSide(
42
- color: isDark
43
- ? const Color(0xFF2A2A2A)
44
- : const Color(0xFFE5E5E5),
45
- ),
38
+ decoration: BoxDecoration(
39
+ color: isDark ? const Color(0xFF0A0A0A) : const Color(0xFFFAFAFA),
40
+ border: Border(
41
+ bottom: BorderSide(
42
+ color: isDark
43
+ ? const Color(0xFF2A2A2A)
44
+ : const Color(0xFFE5E5E5),
45
),
46
),
48
- child: Row(
49
- children: [
50
- Builder(
51
- builder: (context) => IconButton(
52
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedMenu01),
53
- onPressed: () => Scaffold.of(context).openDrawer(),
54
- ),
47
+ ),
48
+ child: Row(
49
+ children: [
50
+ Builder(
51
+ builder: (context) => IconButton(
52
+ icon: const HugeIcon(icon: HugeIcons.strokeRoundedMenu01),
53
+ onPressed: () => Scaffold.of(context).openDrawer(),
54
),
56
- const SizedBox(width: 8),
57
- Text(
58
- l10n.tts_models_title,
59
- style: TextStyle(
60
- fontSize: 20,
61
- fontWeight: FontWeight.bold,
62
- color: isDark ? Colors.white : Colors.black,
63
- ),
55
+ ),
56
+ const SizedBox(width: 8),
57
+ Text(
58
+ l10n.tts_models_title,
59
+ style: TextStyle(
60
+ fontSize: 20,
61
+ fontWeight: FontWeight.bold,
62
+ color: isDark ? Colors.white : Colors.black,
63
),
65
- ],
66
- ),
64
+ ),
65
+ ],
66
),
68
- Expanded(
69
- child: ListView(
70
- padding: const EdgeInsets.all(16),
71
- children: [
72
- _SystemEngineCard(isSelected: settings.ttsEngine == EngineId.system),
73
- const SizedBox(height: 16),
74
- _KittenEngineCard(isSelected: settings.ttsEngine == EngineId.kitten),
75
- const SizedBox(height: 16),
76
- _PiperEngineCard(isSelected: settings.ttsEngine == EngineId.piper),
77
- const SizedBox(height: 24),
78
- const _TtsTestSection(),
79
- const SizedBox(height: 24),
80
- ],
81
- ),
67
+ ),
68
+ Expanded(
69
+ child: ListView(
70
+ padding: const EdgeInsets.all(16),
71
+ children: [
72
+ _SystemEngineCard(
73
+ isSelected: settings.ttsEngine == EngineId.system,
74
+ ),
75
+ const SizedBox(height: 16),
76
+ _KittenEngineCard(
77
+ isSelected: settings.ttsEngine == EngineId.kitten,
78
+ ),
79
+ const SizedBox(height: 16),
80
+ _PiperEngineCard(
81
+ isSelected: settings.ttsEngine == EngineId.piper,
82
+ ),
83
+ const SizedBox(height: 24),
84
+ const _TtsTestSection(),
85
+ const SizedBox(height: 24),
86
+ ],
87
),
88
+ ),
89
],
90
);
91
}
@@ -139,8 +145,8 @@ class _SystemVoicesList extends ConsumerWidget {
145
),
146
const SizedBox(height: 12),
147
ShadAlert(
142
- icon: HugeIcon(icon:
143
- HugeIcons.strokeRoundedInformationCircle,
148
+ icon: HugeIcon(
149
+ icon: HugeIcons.strokeRoundedInformationCircle,
150
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
151
),
152
description: Text(
@@ -205,7 +211,10 @@ class _KittenEngineCard extends ConsumerWidget {
211
child: Row(
212
mainAxisSize: MainAxisSize.min,
213
children: [
208
- const HugeIcon(icon: HugeIcons.strokeRoundedMusicNote01, size: 12),
214
+ const HugeIcon(
215
+ icon: HugeIcons.strokeRoundedMusicNote01,
216
+ size: 12,
217
+ ),
218
const SizedBox(width: 4),
219
Text(
220
l10n.tts_supports_background,
@@ -269,7 +278,11 @@ class _KittenEngineCard extends ConsumerWidget {
278
const SizedBox(width: 8),
279
ShadIconButton.ghost(
280
padding: EdgeInsets.zero,
272
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: Colors.red, size: 20),
281
+ icon: const HugeIcon(
282
+ icon: HugeIcons.strokeRoundedDelete01,
283
+ color: Colors.red,
284
+ size: 20,
285
+ ),
286
onPressed: () => _confirmDelete(context, ref, model),
287
),
288
],
@@ -293,7 +306,10 @@ class _KittenEngineCard extends ConsumerWidget {
306
builder: (context) => AlertDialog(
307
title: Text(l10n.delete_model_title),
308
content: Text(
296
- l10n.delete_model_body_with_size(model.displayName, _formatSize(model.totalSizeBytes)),
309
+ l10n.delete_model_body_with_size(
310
+ model.displayName,
311
+ _formatSize(model.totalSizeBytes),
312
+ ),
313
),
314
actions: [
315
TextButton(
@@ -437,7 +453,10 @@ class _PiperEngineCard extends ConsumerWidget {
453
child: Row(
454
mainAxisSize: MainAxisSize.min,
455
children: [
440
- const HugeIcon(icon: HugeIcons.strokeRoundedMusicNote01, size: 12),
456
+ const HugeIcon(
457
+ icon: HugeIcons.strokeRoundedMusicNote01,
458
+ size: 12,
459
+ ),
460
const SizedBox(width: 4),
461
Text(
462
l10n.tts_supports_background,
@@ -531,7 +550,11 @@ class _PiperEngineCard extends ConsumerWidget {
550
const SizedBox(width: 8),
551
ShadIconButton.ghost(
552
padding: EdgeInsets.zero,
534
- icon: const HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: Colors.red, size: 20),
553
+ icon: const HugeIcon(
554
+ icon: HugeIcons.strokeRoundedDelete01,
555
+ color: Colors.red,
556
+ size: 20,
557
+ ),
558
onPressed: () => _confirmDelete(context, ref, variant),
559
),
560
],
@@ -555,7 +578,10 @@ class _PiperEngineCard extends ConsumerWidget {
578
builder: (context) => AlertDialog(
579
title: Text(l10n.delete_voice_title),
580
content: Text(
558
- l10n.delete_voice_body(variant.displayName, _formatSize(variant.totalSizeBytes)),
581
+ l10n.delete_voice_body(
582
+ variant.displayName,
583
+ _formatSize(variant.totalSizeBytes),
584
+ ),
585
),
586
actions: [
587
TextButton(
@@ -689,7 +715,11 @@ class _PiperVariantChip extends StatelessWidget {
715
),
716
if (installed) ...[
717
const SizedBox(width: 6),
692
- const HugeIcon(icon: HugeIcons.strokeRoundedCheckmarkCircle01, size: 14, color: Colors.green),
718
+ const HugeIcon(
719
+ icon: HugeIcons.strokeRoundedCheckmarkCircle01,
720
+ size: 14,
721
+ color: Colors.green,
722
+ ),
723
],
724
],
725
),
@@ -751,8 +781,8 @@ class _EngineCard extends StatelessWidget {
781
color: accentColor.withValues(alpha: 0.15),
782
borderRadius: BorderRadius.circular(8),
783
),
754
- child: HugeIcon(icon:
755
- HugeIcons.strokeRoundedVoice,
784
+ child: HugeIcon(
785
+ icon: HugeIcons.strokeRoundedVoice,
786
size: 18,
787
color: accentColor,
788
),
@@ -793,7 +823,11 @@ class _EngineCard extends StatelessWidget {
823
),
824
const SizedBox(height: 2),
825
Text(
796
- l10n.engine_spec(meta.sizeMb.toString(), meta.ramMb.toString(), meta.voiceCount),
826
+ l10n.engine_spec(
827
+ meta.sizeMb.toString(),
828
+ meta.ramMb.toString(),
829
+ meta.voiceCount,
830
+ ),
831
style: theme.textTheme.labelSmall?.copyWith(
832
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
833
fontSize: 10,
@@ -890,7 +924,13 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
924
.map(
925
(v) => Padding(
926
padding: const EdgeInsetsDirectional.only(end: 8),
893
- child: _voiceChip(context, v, theme, accent, selectedVoiceId),
927
+ child: _voiceChip(
928
+ context,
929
+ v,
930
+ theme,
931
+ accent,
932
+ selectedVoiceId,
933
+ ),
934
),
935
)
936
.toList(),
@@ -913,7 +953,13 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
953
.map(
954
(v) => Padding(
955
padding: const EdgeInsetsDirectional.only(end: 8),
916
- child: _voiceChip(context, v, theme, accent, selectedVoiceId),
956
+ child: _voiceChip(
957
+ context,
958
+ v,
959
+ theme,
960
+ accent,
961
+ selectedVoiceId,
962
+ ),
963
),
964
)
965
.toList(),
@@ -972,8 +1018,8 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
1018
child: Row(
1019
children: [
1020
if (isSelected) ...[
975
- const HugeIcon(icon:
976
- HugeIcons.strokeRoundedCheckmarkCircle01,
1021
+ const HugeIcon(
1022
+ icon: HugeIcons.strokeRoundedCheckmarkCircle01,
1023
size: 16,
1024
color: Colors.green,
1025
),
@@ -1015,8 +1061,10 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
1061
horizontal: 10,
1062
vertical: 8,
1063
),
1018
- child: HugeIcon(icon:
1019
- isPlaying ? HugeIcons.strokeRoundedStop : HugeIcons.strokeRoundedPlay,
1064
+ child: HugeIcon(
1065
+ icon: isPlaying
1066
+ ? HugeIcons.strokeRoundedStop
1067
+ : HugeIcons.strokeRoundedPlay,
1068
size: 16,
1069
color: isPlaying
1070
? accent
@@ -1047,9 +1095,9 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
1095
} catch (e) {
1096
if (mounted) {
1097
setState(() => _playingVoice = null);
1050
- ScaffoldMessenger.of(
1051
- context,
1052
- ).showSnackBar(SnackBar(content: Text(l10n.preview_failed(e.toString()))));
1098
+ ScaffoldMessenger.of(context).showSnackBar(
1099
+ SnackBar(content: Text(l10n.preview_failed(e.toString()))),
1100
+ );
1101
}
1102
}
1103
}
@@ -1106,7 +1154,9 @@ class _TtsTestSectionState extends ConsumerState<_TtsTestSection> {
1154
decoration: InputDecoration(
1155
hintText: l10n.test_tts_hint,
1156
filled: true,
1109
- fillColor: isDark ? const Color(0xFF1A1A1A) : const Color(0xFFF5F5F5),
1157
+ fillColor: isDark
1158
+ ? const Color(0xFF1A1A1A)
1159
+ : const Color(0xFFF5F5F5),
1160
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
1161
),
1162
),
@@ -1131,4 +1181,4 @@ class _TtsTestSectionState extends ConsumerState<_TtsTestSection> {
1181
],
1182
);
1183
}
1134
-}
\ No newline at end of file
1184
+}
lib/features/voice_mode/providers/voice_mode_provider.dart
+16
-7
@@ -248,35 +248,44 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
248
_isSendingTranscript = false;
249
_generatingFired = false;
250
251
- ref.read(voiceFeedbackProvider).playDisconnected();
251
+ if (ref.mounted) {
252
+ ref.read(voiceFeedbackProvider).playDisconnected();
253
+ }
254
255
// Stop STT if still listening.
256
try {
255
- final stt = ref.read(sttProvider.notifier);
256
- await stt.cancelListening();
257
+ if (ref.mounted) {
258
+ final stt = ref.read(sttProvider.notifier);
259
+ await stt.cancelListening();
260
+ }
261
} catch (e) {
262
Log.error('Voice mode STT cancel error: $e');
263
}
264
265
// Stop TTS if still speaking.
266
try {
263
- final tts = ref.read(ttsProvider.notifier);
264
- await tts.stop();
267
+ if (ref.mounted) {
268
+ final tts = ref.read(ttsProvider.notifier);
269
+ await tts.stop();
270
+ }
271
} catch (e) {
272
Log.error('Voice mode TTS stop error: $e');
273
}
274
269
- state = const VoiceModeState();
275
+ if (ref.mounted) {
276
+ state = const VoiceModeState();
277
+ }
278
}
279
280
/// Manually interrupt speaking and re-listen.
281
Future<void> interrupt() async {
274
- if (!_active) return;
282
+ if (!_active || !ref.mounted) return;
283
try {
284
final tts = ref.read(ttsProvider.notifier);
285
await tts.stop();
286
} catch (_) {}
287
288
+ if (!ref.mounted) return;
289
if (state.autoListen) {
290
startListening();
291
} else {
lib/features/voice_mode/views/components/orb_widget.dart
+3
-5
@@ -151,7 +151,8 @@ class _OrbPainter extends CustomPainter {
151
pulseScale = 1.0 + (math.pow(clampedMic, 0.75) * 0.20);
152
} else if (isSpeaking) {
153
// Organic breathing pulsation synced with TTS speech cadence
154
- final cadence = 0.5 + 0.5 * (math.sin(time * 7.2) * math.sin(time * 3.4));
154
+ final cadence =
155
+ 0.5 + 0.5 * (math.sin(time * 7.2) * math.sin(time * 3.4));
156
pulseScale = 1.0 + (cadence * 0.16);
157
}
158
}
@@ -172,10 +173,7 @@ class _OrbPainter extends CustomPainter {
173
..setFloat(7, glowColor.g)
174
..setFloat(8, glowColor.b);
175
175
- canvas.drawRect(
176
- Offset.zero & size,
177
- Paint()..shader = shader,
178
- );
176
+ canvas.drawRect(Offset.zero & size, Paint()..shader = shader);
177
178
canvas.restore();
179
}
lib/features/voice_mode/views/components/voice_mode_controls.dart
+14
-17
@@ -55,14 +55,15 @@ class VoiceModeControls extends StatelessWidget {
55
icon: phase == VoiceModePhase.listening
56
? HugeIcons.strokeRoundedSent
57
: phase == VoiceModePhase.speaking
58
- ? HugeIcons.strokeRoundedStop
59
- : HugeIcons.strokeRoundedMic01,
58
+ ? HugeIcons.strokeRoundedStop
59
+ : HugeIcons.strokeRoundedMic01,
60
label: phase == VoiceModePhase.listening
61
? 'Send'
62
: phase == VoiceModePhase.speaking
63
- ? 'Stop'
64
- : 'Speak',
65
- isActive: phase == VoiceModePhase.listening ||
63
+ ? 'Stop'
64
+ : 'Speak',
65
+ isActive:
66
+ phase == VoiceModePhase.listening ||
67
phase == VoiceModePhase.speaking,
68
activeColor: VoiceModePalette.accentFor(
69
VoiceModePhase.listening,
@@ -132,14 +133,14 @@ class _PillControl extends StatelessWidget {
133
final bgColor = isActive
134
? activeColor.withValues(alpha: 0.14)
135
: (isDark
135
- ? Colors.white.withValues(alpha: 0.05)
136
- : Colors.black.withValues(alpha: 0.04));
136
+ ? Colors.white.withValues(alpha: 0.05)
137
+ : Colors.black.withValues(alpha: 0.04));
138
139
final iconColor = isActive
140
? activeColor
141
: (isDark
141
- ? Colors.white.withValues(alpha: 0.7)
142
- : Colors.black.withValues(alpha: 0.55));
142
+ ? Colors.white.withValues(alpha: 0.7)
143
+ : Colors.black.withValues(alpha: 0.55));
144
145
return GestureDetector(
146
behavior: HitTestBehavior.opaque,
@@ -161,11 +162,7 @@ class _PillControl extends StatelessWidget {
162
child: Column(
163
mainAxisSize: MainAxisSize.min,
164
children: [
164
- HugeIcon(
165
- icon: icon,
166
- size: 22,
167
- color: iconColor,
168
- ),
165
+ HugeIcon(icon: icon, size: 22, color: iconColor),
166
const SizedBox(height: 4),
167
Text(
168
label,
@@ -176,8 +173,8 @@ class _PillControl extends StatelessWidget {
173
color: isActive
174
? activeColor
175
: (isDark
179
- ? Colors.white.withValues(alpha: 0.55)
180
- : Colors.black.withValues(alpha: 0.5)),
176
+ ? Colors.white.withValues(alpha: 0.55)
177
+ : Colors.black.withValues(alpha: 0.5)),
178
),
179
),
180
],
@@ -185,4 +182,4 @@ class _PillControl extends StatelessWidget {
182
),
183
);
184
}
188
-}
\ No newline at end of file
185
+}
lib/features/voice_mode/views/components/voice_transcript.dart
-1
@@ -859,4 +859,3 @@ class _FadedScrollContainerState extends State<_FadedScrollContainer> {
859
);
860
}
861
}
862
-
lib/features/voice_mode/views/components/voice_visualizer.dart
+1
-4
@@ -265,10 +265,7 @@ class _OrbWavePainter extends CustomPainter {
265
..setFloat(8, secondary.b);
266
267
final shaderPaint = Paint()..shader = shader!;
268
- canvas.drawRect(
269
- Offset.zero & size,
270
- shaderPaint,
271
- );
268
+ canvas.drawRect(Offset.zero & size, shaderPaint);
269
270
canvas.restore();
271
return;
lib/features/voice_mode/voice_mode_palette.dart
+13
-10
@@ -42,7 +42,10 @@ class VoiceModePalette {
42
}
43
44
/// Full 3D sphere gradient stops for a given phase.
45
- static List<Color> sphereGradientFor(VoiceModePhase phase, {required bool isDark}) {
45
+ static List<Color> sphereGradientFor(
46
+ VoiceModePhase phase, {
47
+ required bool isDark,
48
+ }) {
49
final accent = accentFor(phase, isDark: isDark);
50
final secondary = secondaryFor(phase, isDark: isDark);
51
@@ -50,36 +53,36 @@ class VoiceModePalette {
53
case VoiceModePhase.listening:
54
return [
55
const Color(0xFFA7F3D0), // Soft mint top highlight
53
- accent, // Emerald / Teal accent
54
- secondary, // Cyan blue
56
+ accent, // Emerald / Teal accent
57
+ secondary, // Cyan blue
58
const Color(0xFF0284C7), // Deep cyan edge
59
];
60
case VoiceModePhase.processing:
61
return [
62
const Color(0xFFF0ABFC), // Bright violet light top
60
- accent, // Deep purple
61
- secondary, // Magenta shimmer
63
+ accent, // Deep purple
64
+ secondary, // Magenta shimmer
65
const Color(0xFF4C1D95), // Deep dark indigo edge
66
];
67
case VoiceModePhase.speaking:
68
return [
69
const Color(0xFFFFAEE2), // Light peach pink top
70
const Color(0xFFF472B6), // Pink
68
- accent, // Indigo / Purple
69
- secondary, // Deep rose / cyan bottom
71
+ accent, // Indigo / Purple
72
+ secondary, // Deep rose / cyan bottom
73
];
74
case VoiceModePhase.idle:
75
return [
76
const Color(0xFFCBD5E1), // Cool grey light
74
- accent, // Slate blue
75
- secondary, // Dark slate
77
+ accent, // Slate blue
78
+ secondary, // Dark slate
79
const Color(0xFF334155), // Charcoal edge
80
];
81
case VoiceModePhase.error:
82
return [
83
const Color(0xFFFECDD3), // Soft rose pink highlight
84
const Color(0xFFF87171), // Vibrant coral red
82
- accent, // Crimson red
85
+ accent, // Crimson red
86
const Color(0xFF991B1B), // Dark burgundy edge
87
];
88
}
test/core/components/folder_filter_bar_test.dart
+6
-9
@@ -17,9 +17,7 @@ void main() {
17
locale: const Locale('en'),
18
home: Scaffold(
19
body: FolderFilterBar(
20
- folders: const [
21
- FolderFilterItem(id: 'research', name: 'Research'),
22
- ],
20
+ folders: const [FolderFilterItem(id: 'research', name: 'Research')],
21
selectedFolderId: null,
22
onFilterChanged: (_) {},
23
onCreateFolder: () {},
@@ -48,8 +46,9 @@ void main() {
46
expect(capturedPos, isNotNull);
47
});
48
51
- testWidgets('FolderFilterBar does not throw when onFolderAction is null',
52
- (tester) async {
49
+ testWidgets('FolderFilterBar does not throw when onFolderAction is null', (
50
+ tester,
51
+ ) async {
52
await tester.pumpWidget(
53
MaterialApp(
54
localizationsDelegates: AppLocalizations.localizationsDelegates,
@@ -57,9 +56,7 @@ void main() {
56
locale: const Locale('en'),
57
home: Scaffold(
58
body: FolderFilterBar(
60
- folders: const [
61
- FolderFilterItem(id: 'a', name: 'A'),
62
- ],
59
+ folders: const [FolderFilterItem(id: 'a', name: 'A')],
60
selectedFolderId: null,
61
onFilterChanged: (_) {},
62
onCreateFolder: () {},
@@ -72,4 +69,4 @@ void main() {
69
await tester.tap(find.text('A'));
70
expect(tester.takeException(), isNull);
71
});
75
-}
\ No newline at end of file
72
+}
test/core/components/folder_management_dialogs_test.dart
+1
-1
@@ -90,4 +90,4 @@ void main() {
90
await tester.pumpAndSettle();
91
expect(result, isFalse);
92
});
93
-}
\ No newline at end of file
93
+}
test/core/services/crash_report_service_test.dart
+2
-6
@@ -52,9 +52,7 @@ void main() {
52
53
test('shortError sanitizes user-specific paths', () {
54
final report = _makeReport(
55
- error: Exception(
56
- 'File not found: /Users/janedoe/Documents/secret.txt',
57
- ),
55
+ error: Exception('File not found: /Users/janedoe/Documents/secret.txt'),
56
stack: StackTrace.current,
57
);
58
expect(report.shortError, isNot(contains('/Users/janedoe')));
@@ -105,9 +103,7 @@ void main() {
103
104
test('markdownBody sanitizes user-specific paths', () {
105
final report = _makeReport(
108
- error: Exception(
109
- 'File not found: /Users/janedoe/Documents/secret.txt',
110
- ),
106
+ error: Exception('File not found: /Users/janedoe/Documents/secret.txt'),
107
stack: StackTrace.fromString(
108
'#0 frame (/home/johndoe/app/main.dart:1:2)',
109
),
test/core/utils/safe_file_picker_test.dart
+46
-47
@@ -68,10 +68,7 @@ void main() {
68
69
test('returns clean message for FileSystemException', () {
70
final error = const FileSystemException('File is corrupted');
71
- expect(
72
- SafeFilePicker.getErrorMessage(error, l10n),
73
- 'File is corrupted',
74
- );
71
+ expect(SafeFilePicker.getErrorMessage(error, l10n), 'File is corrupted');
72
});
73
74
test('strips Exception prefix for standard exceptions', () {
@@ -89,14 +86,14 @@ void main() {
86
test('pickFiles catches PlatformException and returns null', () async {
87
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
88
.setMockMethodCallHandler(
92
- const MethodChannel('miguelruivo.flutter.plugins.filepicker'),
93
- (MethodCall methodCall) async {
94
- throw PlatformException(
95
- code: 'explorer_not_found',
96
- message: "Can't find a valid activity to handle the request.",
89
+ const MethodChannel('miguelruivo.flutter.plugins.filepicker'),
90
+ (MethodCall methodCall) async {
91
+ throw PlatformException(
92
+ code: 'explorer_not_found',
93
+ message: "Can't find a valid activity to handle the request.",
94
+ );
95
+ },
96
);
98
- },
99
- );
97
98
final result = await SafeFilePicker.pickFiles();
99
expect(result, isNull);
@@ -105,14 +102,14 @@ void main() {
102
test('saveFile catches PlatformException and returns null', () async {
103
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
104
.setMockMethodCallHandler(
108
- const MethodChannel('miguelruivo.flutter.plugins.filepicker'),
109
- (MethodCall methodCall) async {
110
- throw PlatformException(
111
- code: 'explorer_not_found',
112
- message: "Can't find a valid activity to handle the request.",
105
+ const MethodChannel('miguelruivo.flutter.plugins.filepicker'),
106
+ (MethodCall methodCall) async {
107
+ throw PlatformException(
108
+ code: 'explorer_not_found',
109
+ message: "Can't find a valid activity to handle the request.",
110
+ );
111
+ },
112
);
114
- },
115
- );
113
114
final result = await SafeFilePicker.saveFile(
115
dialogTitle: 'Save File',
@@ -121,38 +118,41 @@ void main() {
118
expect(result, isNull);
119
});
120
124
- test('getDirectoryPath catches PlatformException and returns null', () async {
125
- TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
126
- .setMockMethodCallHandler(
127
- const MethodChannel('miguelruivo.flutter.plugins.filepicker'),
128
- (MethodCall methodCall) async {
129
- throw PlatformException(
130
- code: 'explorer_not_found',
131
- message: "Can't find a valid activity to handle the request.",
132
- );
133
- },
134
- );
135
-
136
- final result = await SafeFilePicker.getDirectoryPath();
137
- expect(result, isNull);
138
- });
121
+ test(
122
+ 'getDirectoryPath catches PlatformException and returns null',
123
+ () async {
124
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
125
+ .setMockMethodCallHandler(
126
+ const MethodChannel('miguelruivo.flutter.plugins.filepicker'),
127
+ (MethodCall methodCall) async {
128
+ throw PlatformException(
129
+ code: 'explorer_not_found',
130
+ message: "Can't find a valid activity to handle the request.",
131
+ );
132
+ },
133
+ );
134
+
135
+ final result = await SafeFilePicker.getDirectoryPath();
136
+ expect(result, isNull);
137
+ },
138
+ );
139
140
test('pickFiles returns result when platform succeeds', () async {
141
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
142
.setMockMethodCallHandler(
143
- const MethodChannel('miguelruivo.flutter.plugins.filepicker'),
144
- (MethodCall methodCall) async {
145
- return [
146
- {
147
- 'path': '/path/to/file.txt',
148
- 'name': 'file.txt',
149
- 'size': 123,
150
- 'bytes': null,
151
- 'identifier': null,
152
- }
153
- ];
154
- },
155
- );
143
+ const MethodChannel('miguelruivo.flutter.plugins.filepicker'),
144
+ (MethodCall methodCall) async {
145
+ return [
146
+ {
147
+ 'path': '/path/to/file.txt',
148
+ 'name': 'file.txt',
149
+ 'size': 123,
150
+ 'bytes': null,
151
+ 'identifier': null,
152
+ },
153
+ ];
154
+ },
155
+ );
156
157
final result = await SafeFilePicker.pickFiles();
158
expect(result, isNotNull);
@@ -160,4 +160,3 @@ void main() {
160
});
161
});
162
}
163
-
test/features/chat/chat_input_bar_attachment_test.dart
+11
-10
@@ -15,22 +15,23 @@ import 'package:shadcn_ui/shadcn_ui.dart';
15
import 'package:shared_preferences/shared_preferences.dart';
16
17
void main() {
18
- testWidgets('ChatInputBar handles explorer_not_found error safely',
19
- (tester) async {
18
+ testWidgets('ChatInputBar handles explorer_not_found error safely', (
19
+ tester,
20
+ ) async {
21
SharedPreferences.setMockInitialValues({});
22
final prefs = await SharedPreferences.getInstance();
23
24
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
25
.setMockMethodCallHandler(
25
- const MethodChannel('miguelruivo.flutter.plugins.filepicker'),
26
- (MethodCall methodCall) async {
27
- throw PlatformException(
28
- code: 'explorer_not_found',
29
- message:
30
- "Can't find a valid activity to handle the request. Make sure you have a file explorer installed.",
26
+ const MethodChannel('miguelruivo.flutter.plugins.filepicker'),
27
+ (MethodCall methodCall) async {
28
+ throw PlatformException(
29
+ code: 'explorer_not_found',
30
+ message:
31
+ "Can't find a valid activity to handle the request. Make sure you have a file explorer installed.",
32
+ );
33
+ },
34
);
32
- },
33
- );
35
36
await tester.pumpWidget(
37
ProviderScope(
test/features/chat/chat_mcp_config_notifier_test.dart
+21
-6
@@ -69,15 +69,21 @@ void main() {
69
final state = container.read(chatMcpConfigProvider);
70
expect(state.integrations.length, equals(3));
71
72
- final playwright = state.integrations.firstWhere((i) => i.serverLabel == 'playwright');
72
+ final playwright = state.integrations.firstWhere(
73
+ (i) => i.serverLabel == 'playwright',
74
+ );
75
expect(playwright.type, equals(McpIntegrationType.plugin));
76
expect(playwright.pluginId, equals('mcp/playwright'));
77
76
- final ddgPlugin = state.integrations.firstWhere((i) => i.serverLabel == 'danielsig/duckduckgo');
78
+ final ddgPlugin = state.integrations.firstWhere(
79
+ (i) => i.serverLabel == 'danielsig/duckduckgo',
80
+ );
81
expect(ddgPlugin.type, equals(McpIntegrationType.plugin));
82
expect(ddgPlugin.pluginId, equals('danielsig/duckduckgo'));
83
80
- final ddg = state.integrations.firstWhere((i) => i.serverLabel == 'duckduckgo');
84
+ final ddg = state.integrations.firstWhere(
85
+ (i) => i.serverLabel == 'duckduckgo',
86
+ );
87
expect(ddg.type, equals(McpIntegrationType.ephemeralMcp));
88
expect(ddg.serverUrl, equals('http://localhost:8000/sse'));
89
});
@@ -92,13 +98,22 @@ void main() {
98
),
99
);
100
95
- expect(container.read(chatMcpConfigProvider).integrations[0].enabled, isTrue);
101
+ expect(
102
+ container.read(chatMcpConfigProvider).integrations[0].enabled,
103
+ isTrue,
104
+ );
105
106
notifier.toggleIntegration(0, false);
98
- expect(container.read(chatMcpConfigProvider).integrations[0].enabled, isFalse);
107
+ expect(
108
+ container.read(chatMcpConfigProvider).integrations[0].enabled,
109
+ isFalse,
110
+ );
111
112
notifier.toggleIntegration(0, true);
101
- expect(container.read(chatMcpConfigProvider).integrations[0].enabled, isTrue);
113
+ expect(
114
+ container.read(chatMcpConfigProvider).integrations[0].enabled,
115
+ isTrue,
116
+ );
117
});
118
119
test('addIntegrationFromInput connects an HTTPS MCP server', () {
test/features/chat/chat_service_routing_test.dart
+35
-34
@@ -41,43 +41,44 @@ void main() {
41
});
42
43
group('chatServiceProvider', () {
44
- test('only recreates when loadedRuntime changes, not on every status change',
45
- () async {
46
- SharedPreferences.setMockInitialValues({});
47
- final prefs = await SharedPreferences.getInstance();
48
- final container = ProviderContainer(
49
- overrides: [
50
- sharedPreferencesProvider.overrideWithValue(prefs),
51
- activeServerProvider.overrideWith(_StubActiveServerNotifier.new),
52
- onDeviceGemmaServiceProvider.overrideWithValue(
53
- _FakeOnDeviceGemmaService(),
54
- ),
55
- ],
56
- );
57
- addTearDown(container.dispose);
44
+ test(
45
+ 'only recreates when loadedRuntime changes, not on every status change',
46
+ () async {
47
+ SharedPreferences.setMockInitialValues({});
48
+ final prefs = await SharedPreferences.getInstance();
49
+ final container = ProviderContainer(
50
+ overrides: [
51
+ sharedPreferencesProvider.overrideWithValue(prefs),
52
+ activeServerProvider.overrideWith(_StubActiveServerNotifier.new),
53
+ onDeviceGemmaServiceProvider.overrideWithValue(
54
+ _FakeOnDeviceGemmaService(),
55
+ ),
56
+ ],
57
+ );
58
+ addTearDown(container.dispose);
59
59
- final initialService = container.read(chatServiceProvider);
60
- expect(initialService, isA<OnDeviceChatService>());
60
+ final initialService = container.read(chatServiceProvider);
61
+ expect(initialService, isA<OnDeviceChatService>());
62
62
- // Toggle status without changing runtime — the service instance must be
63
- // preserved so we don't drop an in-flight chat.
64
- container.read(onDeviceEngineProvider.notifier).state =
65
- const OnDeviceEngineState(
66
- status: OnDeviceEngineStatus.loading,
67
- );
68
- final stillSameService = container.read(chatServiceProvider);
69
- expect(identical(initialService, stillSameService), isTrue);
63
+ // Toggle status without changing runtime — the service instance must be
64
+ // preserved so we don't drop an in-flight chat.
65
+ container.read(onDeviceEngineProvider.notifier).state =
66
+ const OnDeviceEngineState(status: OnDeviceEngineStatus.loading);
67
+ final stillSameService = container.read(chatServiceProvider);
68
+ expect(identical(initialService, stillSameService), isTrue);
69
71
- // Switching the runtime rebuilds the service so it can pick up the
72
- // llama.cpp adapter.
73
- container.read(onDeviceEngineProvider.notifier).state =
74
- const OnDeviceEngineState(
75
- status: OnDeviceEngineStatus.loaded,
76
- loadedRuntime: OnDeviceModelRuntime.llamaCpp,
77
- );
78
- final llamaService = container.read(chatServiceProvider);
79
- expect(llamaService, isA<OnDeviceLlamaChatService>());
80
- });
70
+ // Switching the runtime rebuilds the service so it can pick up the
71
+ // llama.cpp adapter.
72
+ container
73
+ .read(onDeviceEngineProvider.notifier)
74
+ .state = const OnDeviceEngineState(
75
+ status: OnDeviceEngineStatus.loaded,
76
+ loadedRuntime: OnDeviceModelRuntime.llamaCpp,
77
+ );
78
+ final llamaService = container.read(chatServiceProvider);
79
+ expect(llamaService, isA<OnDeviceLlamaChatService>());
80
+ },
81
+ );
82
});
83
}
84
test/features/chat/data/chat_error_formatter_test.dart
+12
-11
@@ -4,8 +4,7 @@ import 'package:localmind/features/chat/data/chat_error_formatter.dart';
4
5
void main() {
6
group('ChatErrorFormatter', () {
7
- test('decodes Ollama envelope with type tag and humanises context error',
8
- () {
7
+ test('decodes Ollama envelope with type tag and humanises context error', () {
8
final formatted = ChatErrorFormatter.formatBody(
9
'{"error":{"code":400,"message":"request (4286 tokens) exceeds the available context size (4096 tokens), try increasing it","type":"exceed_context_size_error"}}',
10
statusCode: 400,
@@ -16,14 +15,16 @@ void main() {
15
expect(parsed.message, contains('context window'));
16
});
17
19
- test('decodes Ollama string error and humanises "does not support images"',
20
- () {
21
- final formatted = ChatErrorFormatter.formatBody(
22
- '{"error":"this model does not support images"}',
23
- );
24
- final parsed = ChatApiError.tryParse(formatted)!;
25
- expect(parsed.message, contains('vision model'));
26
- });
18
+ test(
19
+ 'decodes Ollama string error and humanises "does not support images"',
20
+ () {
21
+ final formatted = ChatErrorFormatter.formatBody(
22
+ '{"error":"this model does not support images"}',
23
+ );
24
+ final parsed = ChatApiError.tryParse(formatted)!;
25
+ expect(parsed.message, contains('vision model'));
26
+ },
27
+ );
28
29
test('decodes OpenAI-style nested error and attaches status code', () {
30
final formatted = ChatErrorFormatter.formatBody(
@@ -52,4 +53,4 @@ void main() {
53
expect(parsed.message.toLowerCase(), contains('ollama'));
54
});
55
});
55
-}
\ No newline at end of file
56
+}
test/features/chat/lm_studio_mcp_integration_test.dart
+295
-247
@@ -20,17 +20,19 @@ class CapturingStreamInterceptor extends Interceptor {
20
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
21
capturedRequest = options;
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
- },
23
+ handler.resolve(
24
+ Response(
25
+ requestOptions: options,
26
+ data: ResponseBody(
27
+ controller.stream,
28
+ 200,
29
+ headers: {
30
+ Headers.contentTypeHeader: ['application/json'],
31
+ },
32
+ ),
33
+ statusCode: 200,
34
),
32
- statusCode: 200,
33
- ));
35
+ );
36
37
Future.microtask(() async {
38
for (final line in lines) {
@@ -63,126 +65,153 @@ void main() {
65
createdAt: DateTime.now(),
66
);
67
66
- test('endpoint is /api/v1/chat and body uses input/system_prompt/max_output_tokens', () async {
67
- final interceptor = CapturingStreamInterceptor([
68
- 'event: chat.start',
69
- 'data: {"type":"chat.start","model_instance_id":"test-model"}',
70
- '',
71
- 'event: message.delta',
72
- 'data: {"type":"message.delta","content":"Hello"}',
73
- '',
74
- 'event: chat.end',
75
- 'data: {"type":"chat.end","result":{"model_instance_id":"test-model","output":[],"stats":{"input_tokens":5,"total_output_tokens":1}}}',
76
- '',
77
- ]);
78
- final dio = Dio()..interceptors.add(interceptor);
79
- final service = LMStudioChatService(dio);
80
-
81
- final integrations = [
82
- const McpIntegration(
83
- type: McpIntegrationType.ephemeralMcp,
84
- serverLabel: 'huggingface',
85
- serverUrl: 'https://huggingface.co/mcp',
86
- allowedTools: ['model_search'],
87
- headers: {'Authorization': 'Bearer test-token'},
88
- ),
89
- const McpIntegration(
90
- type: McpIntegrationType.plugin,
91
- pluginId: 'mcp/playwright',
92
- ),
93
- ];
94
-
95
- final responses = await service.sendMessage(
96
- server: server,
97
- modelId: 'test-model',
98
- messages: [userMessage],
99
- params: ChatParameters.defaults(),
100
- integrations: integrations,
101
- ).toList();
102
-
103
- expect(responses.any((r) => r.type == ChatResponseType.message), isTrue);
104
- expect(interceptor.capturedRequest, isNotNull);
105
-
106
- // Endpoint must be the native one.
107
- expect(
108
- interceptor.capturedRequest!.path,
109
- equals('http://localhost:1234/api/v1/chat'),
110
- );
111
-
112
- // Auth header must be propagated when the server has an apiKey.
113
- expect(
114
- interceptor.capturedRequest!.headers['Authorization'],
115
- equals('Bearer test-token'),
116
- );
117
-
118
- final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
119
- expect(body['model'], equals('test-model'));
120
- expect(body['stream'], isTrue);
121
-
122
- // Wire format expectations (per LM Studio's /api/v1/chat spec).
123
- expect(body['input'], isA<String>());
124
- expect(body['input'], contains('Search Hugging Face models'));
125
- expect(body.containsKey('messages'), isFalse,
126
- reason: 'messages is OpenAI-style; the native endpoint uses input');
127
- expect(body.containsKey('max_tokens'), isFalse,
128
- reason: 'native endpoint uses max_output_tokens');
129
- expect(body['max_output_tokens'], isA<int>());
130
-
131
- // Integrations payload shape matches the spec.
132
- expect(body['integrations'], isNotNull);
133
- final bodyIntegrations = body['integrations'] as List;
134
- expect(bodyIntegrations.length, equals(2));
135
-
136
- expect(bodyIntegrations[0], equals({
137
- 'type': 'ephemeral_mcp',
138
- 'server_label': 'huggingface',
139
- 'server_url': 'https://huggingface.co/mcp',
140
- 'allowed_tools': ['model_search'],
141
- 'headers': {'Authorization': 'Bearer test-token'},
142
- }));
143
-
144
- expect(bodyIntegrations[1], equals({
145
- 'type': 'plugin',
146
- 'id': 'mcp/playwright',
147
- }));
148
- });
149
-
150
- test('reasoning control uses native string shape, not OpenAI-compat object', () async {
151
- // Regression: the shared _applyReasoningControl writes `reasoning` as an
152
- // object and extra OpenAI-compat keys (reasoning_effort/think/
153
- // enable_thinking). LM Studio's native /api/v1/chat rejects all of
154
- // those with HTTP 400, so LMStudioChatService must emit the native
155
- // string form ('off'|'low'|'medium'|'high'|'on') only.
156
- final interceptor = CapturingStreamInterceptor([
157
- 'event: chat.start',
158
- 'data: {"type":"chat.start","model_instance_id":"test-model"}',
159
- '',
160
- 'event: chat.end',
161
- 'data: {"type":"chat.end","result":{"model_instance_id":"test-model","output":[],"stats":{"input_tokens":1,"total_output_tokens":1}}}',
162
- '',
163
- ]);
164
- final dio = Dio()..interceptors.add(interceptor);
165
- final service = LMStudioChatService(dio);
166
-
167
- await service.sendMessage(
168
- server: server,
169
- modelId: 'test-model',
170
- messages: [userMessage],
171
- params: ChatParameters.defaults().copyWith(reasoningEnabled: true),
172
- ).toList();
173
-
174
- final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
175
-
176
- // `reasoning` must be a string, never an object.
177
- expect(body['reasoning'], isA<String>(),
178
- reason: 'native endpoint expects reasoning as a string');
179
- expect(body['reasoning'], equals('on'));
68
+ test(
69
+ 'endpoint is /api/v1/chat and body uses input/system_prompt/max_output_tokens',
70
+ () async {
71
+ final interceptor = CapturingStreamInterceptor([
72
+ 'event: chat.start',
73
+ 'data: {"type":"chat.start","model_instance_id":"test-model"}',
74
+ '',
75
+ 'event: message.delta',
76
+ 'data: {"type":"message.delta","content":"Hello"}',
77
+ '',
78
+ 'event: chat.end',
79
+ 'data: {"type":"chat.end","result":{"model_instance_id":"test-model","output":[],"stats":{"input_tokens":5,"total_output_tokens":1}}}',
80
+ '',
81
+ ]);
82
+ final dio = Dio()..interceptors.add(interceptor);
83
+ final service = LMStudioChatService(dio);
84
+
85
+ final integrations = [
86
+ const McpIntegration(
87
+ type: McpIntegrationType.ephemeralMcp,
88
+ serverLabel: 'huggingface',
89
+ serverUrl: 'https://huggingface.co/mcp',
90
+ allowedTools: ['model_search'],
91
+ headers: {'Authorization': 'Bearer test-token'},
92
+ ),
93
+ const McpIntegration(
94
+ type: McpIntegrationType.plugin,
95
+ pluginId: 'mcp/playwright',
96
+ ),
97
+ ];
98
+
99
+ final responses = await service
100
+ .sendMessage(
101
+ server: server,
102
+ modelId: 'test-model',
103
+ messages: [userMessage],
104
+ params: ChatParameters.defaults(),
105
+ integrations: integrations,
106
+ )
107
+ .toList();
108
+
109
+ expect(
110
+ responses.any((r) => r.type == ChatResponseType.message),
111
+ isTrue,
112
+ );
113
+ expect(interceptor.capturedRequest, isNotNull);
114
+
115
+ // Endpoint must be the native one.
116
+ expect(
117
+ interceptor.capturedRequest!.path,
118
+ equals('http://localhost:1234/api/v1/chat'),
119
+ );
120
+
121
+ // Auth header must be propagated when the server has an apiKey.
122
+ expect(
123
+ interceptor.capturedRequest!.headers['Authorization'],
124
+ equals('Bearer test-token'),
125
+ );
126
+
127
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
128
+ expect(body['model'], equals('test-model'));
129
+ expect(body['stream'], isTrue);
130
+
131
+ // Wire format expectations (per LM Studio's /api/v1/chat spec).
132
+ expect(body['input'], isA<String>());
133
+ expect(body['input'], contains('Search Hugging Face models'));
134
+ expect(
135
+ body.containsKey('messages'),
136
+ isFalse,
137
+ reason: 'messages is OpenAI-style; the native endpoint uses input',
138
+ );
139
+ expect(
140
+ body.containsKey('max_tokens'),
141
+ isFalse,
142
+ reason: 'native endpoint uses max_output_tokens',
143
+ );
144
+ expect(body['max_output_tokens'], isA<int>());
145
+
146
+ // Integrations payload shape matches the spec.
147
+ expect(body['integrations'], isNotNull);
148
+ final bodyIntegrations = body['integrations'] as List;
149
+ expect(bodyIntegrations.length, equals(2));
150
+
151
+ expect(
152
+ bodyIntegrations[0],
153
+ equals({
154
+ 'type': 'ephemeral_mcp',
155
+ 'server_label': 'huggingface',
156
+ 'server_url': 'https://huggingface.co/mcp',
157
+ 'allowed_tools': ['model_search'],
158
+ 'headers': {'Authorization': 'Bearer test-token'},
159
+ }),
160
+ );
161
+
162
+ expect(
163
+ bodyIntegrations[1],
164
+ equals({'type': 'plugin', 'id': 'mcp/playwright'}),
165
+ );
166
+ },
167
+ );
168
181
- // The OpenAI-compatible reasoning keys must NOT be sent.
182
- expect(body.containsKey('reasoning_effort'), isFalse);
183
- expect(body.containsKey('think'), isFalse);
184
- expect(body.containsKey('enable_thinking'), isFalse);
185
- });
169
+ test(
170
+ 'reasoning control uses native string shape, not OpenAI-compat object',
171
+ () async {
172
+ // Regression: the shared _applyReasoningControl writes `reasoning` as an
173
+ // object and extra OpenAI-compat keys (reasoning_effort/think/
174
+ // enable_thinking). LM Studio's native /api/v1/chat rejects all of
175
+ // those with HTTP 400, so LMStudioChatService must emit the native
176
+ // string form ('off'|'low'|'medium'|'high'|'on') only.
177
+ final interceptor = CapturingStreamInterceptor([
178
+ 'event: chat.start',
179
+ 'data: {"type":"chat.start","model_instance_id":"test-model"}',
180
+ '',
181
+ 'event: chat.end',
182
+ 'data: {"type":"chat.end","result":{"model_instance_id":"test-model","output":[],"stats":{"input_tokens":1,"total_output_tokens":1}}}',
183
+ '',
184
+ ]);
185
+ final dio = Dio()..interceptors.add(interceptor);
186
+ final service = LMStudioChatService(dio);
187
+
188
+ await service
189
+ .sendMessage(
190
+ server: server,
191
+ modelId: 'test-model',
192
+ messages: [userMessage],
193
+ params: ChatParameters.defaults().copyWith(
194
+ reasoningEnabled: true,
195
+ ),
196
+ )
197
+ .toList();
198
+
199
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
200
+
201
+ // `reasoning` must be a string, never an object.
202
+ expect(
203
+ body['reasoning'],
204
+ isA<String>(),
205
+ reason: 'native endpoint expects reasoning as a string',
206
+ );
207
+ expect(body['reasoning'], equals('on'));
208
+
209
+ // The OpenAI-compatible reasoning keys must NOT be sent.
210
+ expect(body.containsKey('reasoning_effort'), isFalse);
211
+ expect(body.containsKey('think'), isFalse);
212
+ expect(body.containsKey('enable_thinking'), isFalse);
213
+ },
214
+ );
215
216
test('reasoning disabled sends native "off" string', () async {
217
final interceptor = CapturingStreamInterceptor([
@@ -196,12 +225,14 @@ void main() {
225
final dio = Dio()..interceptors.add(interceptor);
226
final service = LMStudioChatService(dio);
227
199
- await service.sendMessage(
200
- server: server,
201
- modelId: 'test-model',
202
- messages: [userMessage],
203
- params: ChatParameters.defaults().copyWith(reasoningEnabled: false),
204
- ).toList();
228
+ await service
229
+ .sendMessage(
230
+ server: server,
231
+ modelId: 'test-model',
232
+ messages: [userMessage],
233
+ params: ChatParameters.defaults().copyWith(reasoningEnabled: false),
234
+ )
235
+ .toList();
236
237
final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
238
expect(body['reasoning'], equals('off'));
@@ -210,66 +241,70 @@ void main() {
241
expect(body.containsKey('enable_thinking'), isFalse);
242
});
243
213
-
214
- test('tool_call.success event surfaces as a completed tool call with output', () async {
215
- final interceptor = CapturingStreamInterceptor([
216
- 'event: chat.start',
217
- 'data: {"type":"chat.start","model_instance_id":"test-model"}',
218
- '',
219
- 'event: tool_call.start',
220
- 'data: {"type":"tool_call.start","tool":"model_search","provider_info":{"type":"ephemeral_mcp","server_label":"huggingface"}}',
221
- '',
222
- 'event: tool_call.arguments',
223
- 'data: {"type":"tool_call.arguments","tool":"model_search","arguments":{"sort":"trendingScore"}}',
224
- '',
225
- 'event: tool_call.success',
226
- 'data: {"type":"tool_call.success","tool":"model_search","arguments":{"sort":"trendingScore"},"output":"Model A, Model B","provider_info":{"type":"ephemeral_mcp","server_label":"huggingface"}}',
227
- '',
228
- 'event: chat.end',
229
- 'data: {"type":"chat.end","result":{"model_instance_id":"test-model","output":[],"stats":{"input_tokens":10,"total_output_tokens":2}}}',
230
- '',
231
- ]);
232
- final dio = Dio()..interceptors.add(interceptor);
233
- final service = LMStudioChatService(dio);
234
-
235
- final responses = await service.sendMessage(
236
- server: server,
237
- modelId: 'test-model',
238
- messages: [userMessage],
239
- params: ChatParameters.defaults(),
240
- integrations: [
241
- const McpIntegration(
242
- type: McpIntegrationType.ephemeralMcp,
243
- serverLabel: 'huggingface',
244
- serverUrl: 'https://huggingface.co/mcp',
245
- ),
246
- ],
247
- ).toList();
248
-
249
- final toolCalls = responses
250
- .where((r) => r.type == ChatResponseType.toolCall)
251
- .toList();
252
- expect(toolCalls.length, equals(1));
253
- expect(toolCalls.first.toolCall?.tool, equals('model_search'));
254
- expect(
255
- toolCalls.first.toolCall?.arguments,
256
- equals({'sort': 'trendingScore'}),
257
- );
258
- // The native endpoint executes the tool server-side; the output
259
- // must be propagated so the client doesn't re-execute it.
260
- expect(toolCalls.first.toolCall?.output, equals('Model A, Model B'));
261
- expect(
262
- toolCalls.first.toolCall?.providerInfo?.serverLabel,
263
- equals('huggingface'),
264
- );
265
-
266
- // The aggregated stats arrive on the chat.end event.
267
- final done = responses
268
- .where((r) => r.type == ChatResponseType.done)
269
- .firstOrNull;
270
- expect(done?.stats?.inputTokens, equals(10));
271
- expect(done?.stats?.totalOutputTokens, equals(2));
272
- });
244
+ test(
245
+ 'tool_call.success event surfaces as a completed tool call with output',
246
+ () async {
247
+ final interceptor = CapturingStreamInterceptor([
248
+ 'event: chat.start',
249
+ 'data: {"type":"chat.start","model_instance_id":"test-model"}',
250
+ '',
251
+ 'event: tool_call.start',
252
+ 'data: {"type":"tool_call.start","tool":"model_search","provider_info":{"type":"ephemeral_mcp","server_label":"huggingface"}}',
253
+ '',
254
+ 'event: tool_call.arguments',
255
+ 'data: {"type":"tool_call.arguments","tool":"model_search","arguments":{"sort":"trendingScore"}}',
256
+ '',
257
+ 'event: tool_call.success',
258
+ 'data: {"type":"tool_call.success","tool":"model_search","arguments":{"sort":"trendingScore"},"output":"Model A, Model B","provider_info":{"type":"ephemeral_mcp","server_label":"huggingface"}}',
259
+ '',
260
+ 'event: chat.end',
261
+ 'data: {"type":"chat.end","result":{"model_instance_id":"test-model","output":[],"stats":{"input_tokens":10,"total_output_tokens":2}}}',
262
+ '',
263
+ ]);
264
+ final dio = Dio()..interceptors.add(interceptor);
265
+ final service = LMStudioChatService(dio);
266
+
267
+ final responses = await service
268
+ .sendMessage(
269
+ server: server,
270
+ modelId: 'test-model',
271
+ messages: [userMessage],
272
+ params: ChatParameters.defaults(),
273
+ integrations: [
274
+ const McpIntegration(
275
+ type: McpIntegrationType.ephemeralMcp,
276
+ serverLabel: 'huggingface',
277
+ serverUrl: 'https://huggingface.co/mcp',
278
+ ),
279
+ ],
280
+ )
281
+ .toList();
282
+
283
+ final toolCalls = responses
284
+ .where((r) => r.type == ChatResponseType.toolCall)
285
+ .toList();
286
+ expect(toolCalls.length, equals(1));
287
+ expect(toolCalls.first.toolCall?.tool, equals('model_search'));
288
+ expect(
289
+ toolCalls.first.toolCall?.arguments,
290
+ equals({'sort': 'trendingScore'}),
291
+ );
292
+ // The native endpoint executes the tool server-side; the output
293
+ // must be propagated so the client doesn't re-execute it.
294
+ expect(toolCalls.first.toolCall?.output, equals('Model A, Model B'));
295
+ expect(
296
+ toolCalls.first.toolCall?.providerInfo?.serverLabel,
297
+ equals('huggingface'),
298
+ );
299
+
300
+ // The aggregated stats arrive on the chat.end event.
301
+ final done = responses
302
+ .where((r) => r.type == ChatResponseType.done)
303
+ .firstOrNull;
304
+ expect(done?.stats?.inputTokens, equals(10));
305
+ expect(done?.stats?.totalOutputTokens, equals(2));
306
+ },
307
+ );
308
309
test('mcp_connection_error surfaces as ChatResponseType.error', () async {
310
final interceptor = CapturingStreamInterceptor([
@@ -280,64 +315,77 @@ void main() {
315
final dio = Dio()..interceptors.add(interceptor);
316
final service = LMStudioChatService(dio);
317
283
- final responses = await service.sendMessage(
284
- server: server,
285
- modelId: 'test-model',
286
- messages: [userMessage],
287
- params: ChatParameters.defaults(),
288
- integrations: [
289
- const McpIntegration(
290
- type: McpIntegrationType.ephemeralMcp,
291
- serverLabel: 'huggingface',
292
- serverUrl: 'https://huggingface.co/mcp',
293
- ),
294
- ],
295
- ).toList();
318
+ final responses = await service
319
+ .sendMessage(
320
+ server: server,
321
+ modelId: 'test-model',
322
+ messages: [userMessage],
323
+ params: ChatParameters.defaults(),
324
+ integrations: [
325
+ const McpIntegration(
326
+ type: McpIntegrationType.ephemeralMcp,
327
+ serverLabel: 'huggingface',
328
+ serverUrl: 'https://huggingface.co/mcp',
329
+ ),
330
+ ],
331
+ )
332
+ .toList();
333
334
final errors = responses
335
.where((r) => r.type == ChatResponseType.error)
336
.toList();
337
expect(errors, hasLength(1));
301
- expect(errors.first.content, contains('Could not reach https://huggingface.co/mcp'));
302
- });
303
-
304
- test('OpenAICompatibleChatService still sends integrations on /v1/chat/completions', () async {
305
- // Sanity check that the OpenAI-compatible path was not regressed.
306
- final openAiServer = Server(
307
- id: 'openai-server',
308
- name: 'OAI Compat',
309
- type: ServerType.openAICompatible,
310
- host: 'localhost',
311
- port: 1234,
312
- createdAt: DateTime.now(),
313
- lastConnectedAt: DateTime.now(),
338
+ expect(
339
+ errors.first.content,
340
+ contains('Could not reach https://huggingface.co/mcp'),
341
);
315
- final interceptor = CapturingStreamInterceptor([
316
- 'data: {"choices":[{"delta":{"content":"Hi"}}]}',
317
- 'data: [DONE]',
318
- ]);
319
- final dio = Dio()..interceptors.add(interceptor);
320
- final service = OpenAICompatibleChatService(dio);
321
-
322
- await service.sendMessage(
323
- server: openAiServer,
324
- modelId: 'test-model',
325
- messages: [userMessage],
326
- params: ChatParameters.defaults(),
327
- integrations: [
328
- const McpIntegration(
329
- type: McpIntegrationType.plugin,
330
- pluginId: 'mcp/filesystem',
331
- ),
332
- ],
333
- ).toList();
334
-
335
- expect(interceptor.capturedRequest, isNotNull);
336
- final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
337
- expect(body['integrations'], isNotNull);
338
- expect(body['integrations'], equals([
339
- {'type': 'plugin', 'id': 'mcp/filesystem'},
340
- ]));
342
});
343
+
344
+ test(
345
+ 'OpenAICompatibleChatService still sends integrations on /v1/chat/completions',
346
+ () async {
347
+ // Sanity check that the OpenAI-compatible path was not regressed.
348
+ final openAiServer = Server(
349
+ id: 'openai-server',
350
+ name: 'OAI Compat',
351
+ type: ServerType.openAICompatible,
352
+ host: 'localhost',
353
+ port: 1234,
354
+ createdAt: DateTime.now(),
355
+ lastConnectedAt: DateTime.now(),
356
+ );
357
+ final interceptor = CapturingStreamInterceptor([
358
+ 'data: {"choices":[{"delta":{"content":"Hi"}}]}',
359
+ 'data: [DONE]',
360
+ ]);
361
+ final dio = Dio()..interceptors.add(interceptor);
362
+ final service = OpenAICompatibleChatService(dio);
363
+
364
+ await service
365
+ .sendMessage(
366
+ server: openAiServer,
367
+ modelId: 'test-model',
368
+ messages: [userMessage],
369
+ params: ChatParameters.defaults(),
370
+ integrations: [
371
+ const McpIntegration(
372
+ type: McpIntegrationType.plugin,
373
+ pluginId: 'mcp/filesystem',
374
+ ),
375
+ ],
376
+ )
377
+ .toList();
378
+
379
+ expect(interceptor.capturedRequest, isNotNull);
380
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
381
+ expect(body['integrations'], isNotNull);
382
+ expect(
383
+ body['integrations'],
384
+ equals([
385
+ {'type': 'plugin', 'id': 'mcp/filesystem'},
386
+ ]),
387
+ );
388
+ },
389
+ );
390
});
391
}
test/features/chat/providers/chat_reasoning_providers_test.dart
+47
-18
@@ -6,15 +6,24 @@ import 'package:localmind/core/models/enums.dart';
6
void main() {
7
group('effortsForModel', () {
8
test('falls back to low/medium/high when efforts are unknown', () {
9
- expect(effortsForModel(null), [ReasoningEffort.low, ReasoningEffort.medium, ReasoningEffort.high]);
10
- expect(effortsForModel(const []), [ReasoningEffort.low, ReasoningEffort.medium, ReasoningEffort.high]);
9
+ expect(effortsForModel(null), [
10
+ ReasoningEffort.low,
11
+ ReasoningEffort.medium,
12
+ ReasoningEffort.high,
13
+ ]);
14
+ expect(effortsForModel(const []), [
15
+ ReasoningEffort.low,
16
+ ReasoningEffort.medium,
17
+ ReasoningEffort.high,
18
+ ]);
19
});
20
21
test('filters and orders advertised efforts lightest to heaviest', () {
14
- expect(
15
- effortsForModel(const ['max', 'high', 'low']),
16
- [ReasoningEffort.low, ReasoningEffort.high, ReasoningEffort.max],
17
- );
22
+ expect(effortsForModel(const ['max', 'high', 'low']), [
23
+ ReasoningEffort.low,
24
+ ReasoningEffort.high,
25
+ ReasoningEffort.max,
26
+ ]);
27
expect(
28
effortsForModel(const ['xhigh', 'high', 'medium', 'low', 'minimal']),
29
[
@@ -28,10 +37,11 @@ void main() {
37
});
38
39
test('falls back when no advertised effort maps to the enum', () {
31
- expect(
32
- effortsForModel(const ['bogus']),
33
- [ReasoningEffort.low, ReasoningEffort.medium, ReasoningEffort.high],
34
- );
40
+ expect(effortsForModel(const ['bogus']), [
41
+ ReasoningEffort.low,
42
+ ReasoningEffort.medium,
43
+ ReasoningEffort.high,
44
+ ]);
45
});
46
});
47
@@ -48,7 +58,10 @@ void main() {
58
}
59
60
test('returns current unchanged when no efforts are advertised', () {
51
- expect(resolveEffortForModel(null, ReasoningEffort.low), ReasoningEffort.low);
61
+ expect(
62
+ resolveEffortForModel(null, ReasoningEffort.low),
63
+ ReasoningEffort.low,
64
+ );
65
expect(
66
resolveEffortForModel(modelWithEfforts(const []), ReasoningEffort.high),
67
ReasoningEffort.high,
@@ -56,19 +69,35 @@ void main() {
69
});
70
71
test('keeps current when it is supported', () {
59
- final model = modelWithEfforts(const ['max', 'high', 'low'], defaultEffort: 'high');
60
- expect(resolveEffortForModel(model, ReasoningEffort.high), ReasoningEffort.high);
72
+ final model = modelWithEfforts(const [
73
+ 'max',
74
+ 'high',
75
+ 'low',
76
+ ], defaultEffort: 'high');
77
+ expect(
78
+ resolveEffortForModel(model, ReasoningEffort.high),
79
+ ReasoningEffort.high,
80
+ );
81
});
82
83
test('snaps to the advertised default when current is unsupported', () {
84
final model = modelWithEfforts(const ['high'], defaultEffort: 'high');
65
- expect(resolveEffortForModel(model, ReasoningEffort.low), ReasoningEffort.high);
85
+ expect(
86
+ resolveEffortForModel(model, ReasoningEffort.low),
87
+ ReasoningEffort.high,
88
+ );
89
});
90
68
- test('picks the heaviest supported effort when no default is advertised', () {
69
- final model = modelWithEfforts(const ['high', 'max']);
70
- expect(resolveEffortForModel(model, ReasoningEffort.low), ReasoningEffort.max);
71
- });
91
+ test(
92
+ 'picks the heaviest supported effort when no default is advertised',
93
+ () {
94
+ final model = modelWithEfforts(const ['high', 'max']);
95
+ expect(
96
+ resolveEffortForModel(model, ReasoningEffort.low),
97
+ ReasoningEffort.max,
98
+ );
99
+ },
100
+ );
101
});
102
103
group('ReasoningEffort.fromApiValue', () {
test/features/chat/providers/model_selection_providers_test.dart
+141
-128
@@ -15,99 +15,106 @@ import 'package:shared_preferences/shared_preferences.dart';
15
16
void main() {
17
group('autoSelectFirstLoadedModelProvider', () {
18
- test('selects the configured default model when it is already loaded',
19
- () async {
20
- SharedPreferences.setMockInitialValues({
21
- 'appSettings': AppSettings(
22
- defaultModelId: 'model-a',
23
- defaultModelServerId: 'server-1',
24
- ).toJson(),
25
- });
26
- final prefs = await SharedPreferences.getInstance();
18
+ test(
19
+ 'selects the configured default model when it is already loaded',
20
+ () async {
21
+ SharedPreferences.setMockInitialValues({
22
+ 'appSettings': AppSettings(
23
+ defaultModelId: 'model-a',
24
+ defaultModelServerId: 'server-1',
25
+ ).toJson(),
26
+ });
27
+ final prefs = await SharedPreferences.getInstance();
28
28
- final container = ProviderContainer(
29
- overrides: [
30
- sharedPreferencesProvider.overrideWithValue(prefs),
31
- activeServerProvider.overrideWith(_StubActiveServerNotifier.new),
32
- availableModelsProvider('server-1').overrideWith(
33
- (ref) async => _models(),
34
- ),
35
- ],
36
- );
37
- addTearDown(container.dispose);
29
+ final container = ProviderContainer(
30
+ overrides: [
31
+ sharedPreferencesProvider.overrideWithValue(prefs),
32
+ activeServerProvider.overrideWith(_StubActiveServerNotifier.new),
33
+ availableModelsProvider(
34
+ 'server-1',
35
+ ).overrideWith((ref) async => _models()),
36
+ ],
37
+ );
38
+ addTearDown(container.dispose);
39
39
- // Mark the on-device engine as having the default model loaded.
40
- container.read(onDeviceEngineProvider.notifier).state =
41
- const OnDeviceEngineState(
42
- status: OnDeviceEngineStatus.loaded,
43
- loadedModelId: 'model-a',
44
- );
40
+ // Mark the on-device engine as having the default model loaded.
41
+ container
42
+ .read(onDeviceEngineProvider.notifier)
43
+ .state = const OnDeviceEngineState(
44
+ status: OnDeviceEngineStatus.loaded,
45
+ loadedModelId: 'model-a',
46
+ );
47
46
- await container.read(autoSelectFirstLoadedModelProvider.future);
48
+ await container.read(autoSelectFirstLoadedModelProvider.future);
49
48
- final selected = container.read(selectedModelProvider);
49
- expect(selected?.id, 'model-a');
50
- });
50
+ final selected = container.read(selectedModelProvider);
51
+ expect(selected?.id, 'model-a');
52
+ },
53
+ );
54
52
- test('selects the default model even when it is not loaded for LM Studio',
53
- () async {
54
- SharedPreferences.setMockInitialValues({
55
- 'appSettings': AppSettings(
56
- defaultModelId: 'model-a',
57
- defaultModelServerId: 'server-1',
58
- ).toJson(),
59
- });
60
- final prefs = await SharedPreferences.getInstance();
55
+ test(
56
+ 'selects the default model even when it is not loaded for LM Studio',
57
+ () async {
58
+ SharedPreferences.setMockInitialValues({
59
+ 'appSettings': AppSettings(
60
+ defaultModelId: 'model-a',
61
+ defaultModelServerId: 'server-1',
62
+ ).toJson(),
63
+ });
64
+ final prefs = await SharedPreferences.getInstance();
65
62
- final api = _FakeServerApiService(loadedModelIds: const {});
63
- final container = ProviderContainer(
64
- overrides: [
65
- sharedPreferencesProvider.overrideWithValue(prefs),
66
- activeServerProvider.overrideWith(_StubLmStudioActiveServer.new),
67
- connectionStatusProvider.overrideWith(
68
- _StubConnectedStatusNotifier.new,
69
- ),
70
- availableModelsProvider('server-1').overrideWith(
71
- (ref) async => _models(),
72
- ),
73
- serverApiServiceProvider.overrideWithValue(api),
74
- ],
75
- );
76
- addTearDown(container.dispose);
66
+ final api = _FakeServerApiService(loadedModelIds: const {});
67
+ final container = ProviderContainer(
68
+ overrides: [
69
+ sharedPreferencesProvider.overrideWithValue(prefs),
70
+ activeServerProvider.overrideWith(_StubLmStudioActiveServer.new),
71
+ connectionStatusProvider.overrideWith(
72
+ _StubConnectedStatusNotifier.new,
73
+ ),
74
+ availableModelsProvider(
75
+ 'server-1',
76
+ ).overrideWith((ref) async => _models()),
77
+ serverApiServiceProvider.overrideWithValue(api),
78
+ ],
79
+ );
80
+ addTearDown(container.dispose);
81
78
- await container.read(autoSelectFirstLoadedModelProvider.future);
82
+ await container.read(autoSelectFirstLoadedModelProvider.future);
83
80
- final selected = container.read(selectedModelProvider);
81
- expect(selected?.id, 'model-a');
82
- expect(api.loadedIds, contains('model-a'));
83
- });
84
+ final selected = container.read(selectedModelProvider);
85
+ expect(selected?.id, 'model-a');
86
+ expect(api.loadedIds, contains('model-a'));
87
+ },
88
+ );
89
85
- test('falls back to the first loaded model when no default is set',
86
- () async {
87
- SharedPreferences.setMockInitialValues({});
88
- final prefs = await SharedPreferences.getInstance();
90
+ test(
91
+ 'falls back to the first loaded model when no default is set',
92
+ () async {
93
+ SharedPreferences.setMockInitialValues({});
94
+ final prefs = await SharedPreferences.getInstance();
95
90
- final api = _FakeServerApiService(loadedModelIds: const {'model-b'});
91
- final container = ProviderContainer(
92
- overrides: [
93
- sharedPreferencesProvider.overrideWithValue(prefs),
94
- activeServerProvider.overrideWith(_StubLmStudioActiveServer.new),
95
- connectionStatusProvider.overrideWith(
96
- _StubConnectedStatusNotifier.new,
97
- ),
98
- availableModelsProvider('server-1').overrideWith(
99
- (ref) async => _models(),
100
- ),
101
- serverApiServiceProvider.overrideWithValue(api),
102
- ],
103
- );
104
- addTearDown(container.dispose);
96
+ final api = _FakeServerApiService(loadedModelIds: const {'model-b'});
97
+ final container = ProviderContainer(
98
+ overrides: [
99
+ sharedPreferencesProvider.overrideWithValue(prefs),
100
+ activeServerProvider.overrideWith(_StubLmStudioActiveServer.new),
101
+ connectionStatusProvider.overrideWith(
102
+ _StubConnectedStatusNotifier.new,
103
+ ),
104
+ availableModelsProvider(
105
+ 'server-1',
106
+ ).overrideWith((ref) async => _models()),
107
+ serverApiServiceProvider.overrideWithValue(api),
108
+ ],
109
+ );
110
+ addTearDown(container.dispose);
111
106
- await container.read(autoSelectFirstLoadedModelProvider.future);
112
+ await container.read(autoSelectFirstLoadedModelProvider.future);
113
108
- final selected = container.read(selectedModelProvider);
109
- expect(selected?.id, 'model-b');
110
- });
114
+ final selected = container.read(selectedModelProvider);
115
+ expect(selected?.id, 'model-b');
116
+ },
117
+ );
118
119
test('selects the default model for cloud providers', () async {
120
SharedPreferences.setMockInitialValues({
@@ -125,9 +132,9 @@ void main() {
132
connectionStatusProvider.overrideWith(
133
_StubConnectedStatusNotifier.new,
134
),
128
- availableModelsProvider('server-1').overrideWith(
129
- (ref) async => _models(),
130
- ),
135
+ availableModelsProvider(
136
+ 'server-1',
137
+ ).overrideWith((ref) async => _models()),
138
],
139
);
140
addTearDown(container.dispose);
@@ -138,57 +145,60 @@ void main() {
145
expect(selected?.id, 'model-a');
146
});
147
141
- test('does not throw or access disposed Ref when container is disposed during execution',
142
- () async {
143
- SharedPreferences.setMockInitialValues({
144
- 'appSettings': AppSettings(
145
- defaultModelId: 'model-a',
146
- defaultModelServerId: 'server-1',
147
- ).toJson(),
148
- });
149
- final prefs = await SharedPreferences.getInstance();
148
+ test(
149
+ 'does not throw or access disposed Ref when container is disposed during execution',
150
+ () async {
151
+ SharedPreferences.setMockInitialValues({
152
+ 'appSettings': AppSettings(
153
+ defaultModelId: 'model-a',
154
+ defaultModelServerId: 'server-1',
155
+ ).toJson(),
156
+ });
157
+ final prefs = await SharedPreferences.getInstance();
158
151
- final api = _FakeServerApiService(loadedModelIds: const {});
152
- final container = ProviderContainer(
153
- overrides: [
154
- sharedPreferencesProvider.overrideWithValue(prefs),
155
- activeServerProvider.overrideWith(_StubLmStudioActiveServer.new),
156
- connectionStatusProvider.overrideWith(
157
- _StubConnectedStatusNotifier.new,
158
- ),
159
- availableModelsProvider('server-1').overrideWith(
160
- (ref) async {
159
+ final api = _FakeServerApiService(loadedModelIds: const {});
160
+ final container = ProviderContainer(
161
+ overrides: [
162
+ sharedPreferencesProvider.overrideWithValue(prefs),
163
+ activeServerProvider.overrideWith(_StubLmStudioActiveServer.new),
164
+ connectionStatusProvider.overrideWith(
165
+ _StubConnectedStatusNotifier.new,
166
+ ),
167
+ availableModelsProvider('server-1').overrideWith((ref) async {
168
await Future.delayed(const Duration(milliseconds: 50));
169
return _models();
163
- },
164
- ),
165
- serverApiServiceProvider.overrideWithValue(api),
166
- ],
167
- );
170
+ }),
171
+ serverApiServiceProvider.overrideWithValue(api),
172
+ ],
173
+ );
174
169
- // Start the future and dispose container immediately
170
- final future = container.read(autoSelectFirstLoadedModelProvider.future);
171
- container.dispose();
175
+ // Start the future and dispose container immediately
176
+ final future = container.read(
177
+ autoSelectFirstLoadedModelProvider.future,
178
+ );
179
+ container.dispose();
180
173
- // Should complete without uncaught StateError
174
- expect(future, completes);
175
- });
181
+ // Should complete without uncaught StateError
182
+ expect(future, completes);
183
+ },
184
+ );
185
});
186
}
187
+
188
List<ModelInfo> _models() => [
179
- ModelInfo(
180
- id: 'model-a',
181
- name: 'Model A',
182
- serverType: ServerType.lmStudio,
183
- serverId: 'server-1',
184
- ),
185
- ModelInfo(
186
- id: 'model-b',
187
- name: 'Model B',
188
- serverType: ServerType.lmStudio,
189
- serverId: 'server-1',
190
- ),
191
- ];
189
+ ModelInfo(
190
+ id: 'model-a',
191
+ name: 'Model A',
192
+ serverType: ServerType.lmStudio,
193
+ serverId: 'server-1',
194
+ ),
195
+ ModelInfo(
196
+ id: 'model-b',
197
+ name: 'Model B',
198
+ serverType: ServerType.lmStudio,
199
+ serverId: 'server-1',
200
+ ),
201
+];
202
203
class _StubActiveServerNotifier extends ActiveServerNotifier {
204
@override
@@ -243,5 +253,8 @@ class _FakeServerApiService extends ServerApiService {
253
}
254
255
@override
246
- Future<void> unloadAllInstances(Server server, Set<String> instanceIds) async {}
256
+ Future<void> unloadAllInstances(
257
+ Server server,
258
+ Set<String> instanceIds,
259
+ ) async {}
260
}
test/features/chat/sequential_post_generation_test.dart
+4
-1
@@ -184,7 +184,10 @@ void main() {
184
}
185
186
class _MockChatNotifier extends ChatNotifier {
187
- _MockChatNotifier({this.initialState = const ChatState(), this.onWaitForTitle});
187
+ _MockChatNotifier({
188
+ this.initialState = const ChatState(),
189
+ this.onWaitForTitle,
190
+ });
191
192
final ChatState initialState;
193
final Future<void> Function(String convId)? onWaitForTitle;
test/features/chat/tools/chat_service_adapter_selection_test.dart
+308
-278
@@ -23,17 +23,19 @@ class StreamInterceptor extends Interceptor {
23
@override
24
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
25
final controller = StreamController<Uint8List>();
26
- handler.resolve(Response(
27
- requestOptions: options,
28
- data: ResponseBody(
29
- controller.stream,
30
- statusCode,
31
- headers: {
32
- Headers.contentTypeHeader: ['application/json'],
33
- },
26
+ handler.resolve(
27
+ Response(
28
+ requestOptions: options,
29
+ data: ResponseBody(
30
+ controller.stream,
31
+ statusCode,
32
+ headers: {
33
+ Headers.contentTypeHeader: ['application/json'],
34
+ },
35
+ ),
36
+ statusCode: statusCode,
37
),
35
- statusCode: statusCode,
36
- ));
38
+ );
39
40
// Emit lines asynchronously
41
Future.microtask(() async {
@@ -82,114 +84,120 @@ void main() {
84
});
85
86
group('ChatService implementation tool call adapter integration', () {
85
- test('OpenAICompatibleChatService uses OpenAiToolAdapter and parses OpenAI stream tool calls', () async {
86
- final lines = [
87
- 'data: {"choices": [{"delta": {"tool_calls": [{"index": 0, "id": "call_openai_1", "type": "function", "function": {"name": "calc.add", "arguments": ""}}]}}]}',
88
- 'data: {"choices": [{"delta": {"tool_calls": [{"index": 0, "function": {"arguments": "{\\"a\\":1,\\"b\\":2}"}}]}}]}',
89
- 'data: [DONE]'
90
- ];
91
-
92
- final dio = Dio()..interceptors.add(StreamInterceptor(lines));
93
- final service = OpenAICompatibleChatService(dio);
94
-
95
- final server = Server(
96
- id: 'test-openai',
97
- name: 'Test OpenAI',
98
- type: ServerType.openAICompatible,
99
- host: 'localhost',
100
- port: 8080,
101
- createdAt: DateTime.now(),
102
- lastConnectedAt: DateTime.now(),
103
- );
104
-
105
- final responseStream = service.sendMessage(
106
- server: server,
107
- modelId: 'test-model',
108
- messages: [
109
- Message(
110
- id: 'msg_1',
111
- conversationId: 'conv_1',
112
- role: MessageRole.user,
113
- content: 'hello',
114
- createdAt: DateTime.now(),
115
- )
116
- ],
117
- params: ChatParameters.defaults(),
118
- tools: [
119
- const ToolDefinition(
120
- name: 'calc.add',
121
- description: 'Add two numbers',
122
- inputSchema: {'type': 'object'},
123
- providerType: ToolProviderType.builtIn,
124
- )
125
- ],
126
- );
87
+ test(
88
+ 'OpenAICompatibleChatService uses OpenAiToolAdapter and parses OpenAI stream tool calls',
89
+ () async {
90
+ final lines = [
91
+ 'data: {"choices": [{"delta": {"tool_calls": [{"index": 0, "id": "call_openai_1", "type": "function", "function": {"name": "calc.add", "arguments": ""}}]}}]}',
92
+ 'data: {"choices": [{"delta": {"tool_calls": [{"index": 0, "function": {"arguments": "{\\"a\\":1,\\"b\\":2}"}}]}}]}',
93
+ 'data: [DONE]',
94
+ ];
95
+
96
+ final dio = Dio()..interceptors.add(StreamInterceptor(lines));
97
+ final service = OpenAICompatibleChatService(dio);
98
+
99
+ final server = Server(
100
+ id: 'test-openai',
101
+ name: 'Test OpenAI',
102
+ type: ServerType.openAICompatible,
103
+ host: 'localhost',
104
+ port: 8080,
105
+ createdAt: DateTime.now(),
106
+ lastConnectedAt: DateTime.now(),
107
+ );
108
128
- final responses = await responseStream.toList();
129
- final toolCalls = responses
130
- .where((r) => r.type == ChatResponseType.toolCall)
131
- .map((r) => r.toolCall)
132
- .toList();
109
+ final responseStream = service.sendMessage(
110
+ server: server,
111
+ modelId: 'test-model',
112
+ messages: [
113
+ Message(
114
+ id: 'msg_1',
115
+ conversationId: 'conv_1',
116
+ role: MessageRole.user,
117
+ content: 'hello',
118
+ createdAt: DateTime.now(),
119
+ ),
120
+ ],
121
+ params: ChatParameters.defaults(),
122
+ tools: [
123
+ const ToolDefinition(
124
+ name: 'calc.add',
125
+ description: 'Add two numbers',
126
+ inputSchema: {'type': 'object'},
127
+ providerType: ToolProviderType.builtIn,
128
+ ),
129
+ ],
130
+ );
131
134
- expect(toolCalls, isNotEmpty);
135
- expect(toolCalls.first!.tool, 'calc.add');
136
- expect(toolCalls.first!.arguments['a'], 1);
137
- expect(toolCalls.first!.arguments['b'], 2);
138
- });
132
+ final responses = await responseStream.toList();
133
+ final toolCalls = responses
134
+ .where((r) => r.type == ChatResponseType.toolCall)
135
+ .map((r) => r.toolCall)
136
+ .toList();
137
140
- test('OllamaChatService uses OllamaToolAdapter and parses Ollama stream tool calls', () async {
141
- final lines = [
142
- '{"message": {"tool_calls": [{"id": "call_ollama_1", "function": {"name": "calc.multiply", "arguments": "{\\"a\\":5,\\"b\\":6}"}}]}}',
143
- '{"done": true}'
144
- ];
138
+ expect(toolCalls, isNotEmpty);
139
+ expect(toolCalls.first!.tool, 'calc.add');
140
+ expect(toolCalls.first!.arguments['a'], 1);
141
+ expect(toolCalls.first!.arguments['b'], 2);
142
+ },
143
+ );
144
146
- final dio = Dio()..interceptors.add(StreamInterceptor(lines));
147
- final service = OllamaChatService(dio);
148
-
149
- final server = Server(
150
- id: 'test-ollama',
151
- name: 'Test Ollama',
152
- type: ServerType.ollama,
153
- host: 'localhost',
154
- port: 11434,
155
- createdAt: DateTime.now(),
156
- lastConnectedAt: DateTime.now(),
157
- );
145
+ test(
146
+ 'OllamaChatService uses OllamaToolAdapter and parses Ollama stream tool calls',
147
+ () async {
148
+ final lines = [
149
+ '{"message": {"tool_calls": [{"id": "call_ollama_1", "function": {"name": "calc.multiply", "arguments": "{\\"a\\":5,\\"b\\":6}"}}]}}',
150
+ '{"done": true}',
151
+ ];
152
+
153
+ final dio = Dio()..interceptors.add(StreamInterceptor(lines));
154
+ final service = OllamaChatService(dio);
155
+
156
+ final server = Server(
157
+ id: 'test-ollama',
158
+ name: 'Test Ollama',
159
+ type: ServerType.ollama,
160
+ host: 'localhost',
161
+ port: 11434,
162
+ createdAt: DateTime.now(),
163
+ lastConnectedAt: DateTime.now(),
164
+ );
165
159
- final responseStream = service.sendMessage(
160
- server: server,
161
- modelId: 'test-model',
162
- messages: [
163
- Message(
164
- id: 'msg_2',
165
- conversationId: 'conv_2',
166
- role: MessageRole.user,
167
- content: 'hello',
168
- createdAt: DateTime.now(),
169
- )
170
- ],
171
- params: ChatParameters.defaults(),
172
- tools: [
173
- const ToolDefinition(
174
- name: 'calc.multiply',
175
- description: 'Multiply two numbers',
176
- inputSchema: {'type': 'object'},
177
- providerType: ToolProviderType.builtIn,
178
- )
179
- ],
180
- );
166
+ final responseStream = service.sendMessage(
167
+ server: server,
168
+ modelId: 'test-model',
169
+ messages: [
170
+ Message(
171
+ id: 'msg_2',
172
+ conversationId: 'conv_2',
173
+ role: MessageRole.user,
174
+ content: 'hello',
175
+ createdAt: DateTime.now(),
176
+ ),
177
+ ],
178
+ params: ChatParameters.defaults(),
179
+ tools: [
180
+ const ToolDefinition(
181
+ name: 'calc.multiply',
182
+ description: 'Multiply two numbers',
183
+ inputSchema: {'type': 'object'},
184
+ providerType: ToolProviderType.builtIn,
185
+ ),
186
+ ],
187
+ );
188
182
- final responses = await responseStream.toList();
183
- final toolCalls = responses
184
- .where((r) => r.type == ChatResponseType.toolCall)
185
- .map((r) => r.toolCall)
186
- .toList();
189
+ final responses = await responseStream.toList();
190
+ final toolCalls = responses
191
+ .where((r) => r.type == ChatResponseType.toolCall)
192
+ .map((r) => r.toolCall)
193
+ .toList();
194
188
- expect(toolCalls, isNotEmpty);
189
- expect(toolCalls.first!.tool, 'calc.multiply');
190
- expect(toolCalls.first!.arguments['a'], 5);
191
- expect(toolCalls.first!.arguments['b'], 6);
192
- });
195
+ expect(toolCalls, isNotEmpty);
196
+ expect(toolCalls.first!.tool, 'calc.multiply');
197
+ expect(toolCalls.first!.arguments['a'], 5);
198
+ expect(toolCalls.first!.arguments['b'], 6);
199
+ },
200
+ );
201
});
202
203
group('Ollama vision message formatting', () {
@@ -347,15 +355,14 @@ void main() {
355
expect(
356
parsed!.message,
357
'The selected model does not support images. Choose a vision '
350
- 'model such as llava, qwen2.5vl, gemma3, or llama3.2-vision.',
358
+ 'model such as llava, qwen2.5vl, gemma3, or llama3.2-vision.',
359
);
360
});
361
362
test('decodes Ollama HTTP 400 error bodies', () async {
355
- final interceptor = StreamInterceptor(
356
- ['{"error":"unable to decode image"}'],
357
- statusCode: 400,
358
- );
363
+ final interceptor = StreamInterceptor([
364
+ '{"error":"unable to decode image"}',
365
+ ], statusCode: 400);
366
final service = OllamaChatService(Dio()..interceptors.add(interceptor));
367
368
final responses = await service
@@ -382,19 +389,16 @@ void main() {
389
expect(
390
parsed!.message,
391
'The server could not decode the attached image. Try a smaller '
385
- 'or different-format image, or enable image compression in Settings.',
392
+ 'or different-format image, or enable image compression in Settings.',
393
);
394
});
395
396
test(
397
'decodes structured Ollama HTTP 400 error envelope with type tag',
398
() async {
392
- final interceptor = StreamInterceptor(
393
- [
394
- '{"error":{"code":400,"message":"request (4286 tokens) exceeds the available context size (4096 tokens), try increasing it","type":"exceed_context_size_error","n_prompt_tokens":4286,"n_ctx":4096}}',
395
- ],
396
- statusCode: 400,
397
- );
399
+ final interceptor = StreamInterceptor([
400
+ '{"error":{"code":400,"message":"request (4286 tokens) exceeds the available context size (4096 tokens), try increasing it","type":"exceed_context_size_error","n_prompt_tokens":4286,"n_ctx":4096}}',
401
+ ], statusCode: 400);
402
final service = OllamaChatService(Dio()..interceptors.add(interceptor));
403
404
final responses = await service
@@ -421,7 +425,7 @@ void main() {
425
expect(
426
parsed!.message,
427
'The conversation is longer than the model\'s context window. '
424
- 'Increase `num_ctx` for the model or start a new chat.',
428
+ 'Increase `num_ctx` for the model or start a new chat.',
429
);
430
expect(parsed.type, 'exceed_context_size_error');
431
expect(parsed.code, '400');
@@ -446,12 +450,25 @@ void main() {
450
451
test('sends image attachments as base64 data-URL content parts', () async {
452
// Minimal PNG and JPEG headers so mime-type detection is exercised.
449
- final pngBytes = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02];
453
+ final pngBytes = [
454
+ 0x89,
455
+ 0x50,
456
+ 0x4e,
457
+ 0x47,
458
+ 0x0d,
459
+ 0x0a,
460
+ 0x1a,
461
+ 0x0a,
462
+ 0x01,
463
+ 0x02,
464
+ ];
465
final jpegBytes = [0xff, 0xd8, 0xff, 0xe0, 0x03, 0x04, 0x05];
451
- final png = await File('${tempDirectory.path}/photo.png')
452
- .writeAsBytes(pngBytes);
453
- final jpeg = await File('${tempDirectory.path}/photo.jpg')
454
- .writeAsBytes(jpegBytes);
466
+ final png = await File(
467
+ '${tempDirectory.path}/photo.png',
468
+ ).writeAsBytes(pngBytes);
469
+ final jpeg = await File(
470
+ '${tempDirectory.path}/photo.jpg',
471
+ ).writeAsBytes(jpegBytes);
472
final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
473
final service = OpenAICompatibleChatService(
474
Dio()..interceptors.add(interceptor),
@@ -480,54 +497,50 @@ void main() {
497
final content = (body['messages'] as List).single['content'] as List;
498
expect(content, hasLength(3));
499
expect(content[0], {'type': 'text', 'text': 'What is shown?'});
483
- expect(
484
- content[1],
485
- {
486
- 'type': 'image_url',
487
- 'image_url': {
488
- 'url': 'data:image/png;base64,${base64Encode(pngBytes)}',
489
- },
490
- },
491
- );
492
- expect(
493
- content[2],
494
- {
495
- 'type': 'image_url',
496
- 'image_url': {
497
- 'url': 'data:image/jpeg;base64,${base64Encode(jpegBytes)}',
498
- },
500
+ expect(content[1], {
501
+ 'type': 'image_url',
502
+ 'image_url': {'url': 'data:image/png;base64,${base64Encode(pngBytes)}'},
503
+ });
504
+ expect(content[2], {
505
+ 'type': 'image_url',
506
+ 'image_url': {
507
+ 'url': 'data:image/jpeg;base64,${base64Encode(jpegBytes)}',
508
},
500
- );
509
+ });
510
expect(responses.last.type, ChatResponseType.done);
511
});
512
504
- test('keeps messages without attachments as plain string content', () async {
505
- final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
506
- final service = OpenAICompatibleChatService(
507
- Dio()..interceptors.add(interceptor),
508
- );
513
+ test(
514
+ 'keeps messages without attachments as plain string content',
515
+ () async {
516
+ final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
517
+ final service = OpenAICompatibleChatService(
518
+ Dio()..interceptors.add(interceptor),
519
+ );
520
510
- await service
511
- .sendMessage(
512
- server: _openAiTestServer(),
513
- modelId: 'text-model',
514
- messages: [
515
- Message(
516
- id: 'text-message',
517
- conversationId: 'conversation',
518
- role: MessageRole.user,
519
- content: 'hello',
520
- createdAt: DateTime.now(),
521
- ),
522
- ],
523
- params: ChatParameters.defaults(),
524
- )
525
- .toList();
521
+ await service
522
+ .sendMessage(
523
+ server: _openAiTestServer(),
524
+ modelId: 'text-model',
525
+ messages: [
526
+ Message(
527
+ id: 'text-message',
528
+ conversationId: 'conversation',
529
+ role: MessageRole.user,
530
+ content: 'hello',
531
+ createdAt: DateTime.now(),
532
+ ),
533
+ ],
534
+ params: ChatParameters.defaults(),
535
+ )
536
+ .toList();
537
527
- final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
528
- final message = (body['messages'] as List).single as Map<String, dynamic>;
529
- expect(message['content'], 'hello');
530
- });
538
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
539
+ final message =
540
+ (body['messages'] as List).single as Map<String, dynamic>;
541
+ expect(message['content'], 'hello');
542
+ },
543
+ );
544
545
test('inlines text attachment contents into the user message', () async {
546
final textFile = await File(
@@ -564,35 +577,39 @@ void main() {
577
);
578
});
579
567
- test('skips missing image attachments without aborting the request', () async {
568
- final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
569
- final service = OpenAICompatibleChatService(
570
- Dio()..interceptors.add(interceptor),
571
- );
580
+ test(
581
+ 'skips missing image attachments without aborting the request',
582
+ () async {
583
+ final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
584
+ final service = OpenAICompatibleChatService(
585
+ Dio()..interceptors.add(interceptor),
586
+ );
587
573
- final responses = await service
574
- .sendMessage(
575
- server: _openAiTestServer(),
576
- modelId: 'vision-model',
577
- messages: [
578
- Message(
579
- id: 'missing-image-message',
580
- conversationId: 'conversation',
581
- role: MessageRole.user,
582
- content: 'Continue without the missing image',
583
- attachmentPaths: ['${tempDirectory.path}/missing.png'],
584
- createdAt: DateTime.now(),
585
- ),
586
- ],
587
- params: ChatParameters.defaults(),
588
- )
589
- .toList();
588
+ final responses = await service
589
+ .sendMessage(
590
+ server: _openAiTestServer(),
591
+ modelId: 'vision-model',
592
+ messages: [
593
+ Message(
594
+ id: 'missing-image-message',
595
+ conversationId: 'conversation',
596
+ role: MessageRole.user,
597
+ content: 'Continue without the missing image',
598
+ attachmentPaths: ['${tempDirectory.path}/missing.png'],
599
+ createdAt: DateTime.now(),
600
+ ),
601
+ ],
602
+ params: ChatParameters.defaults(),
603
+ )
604
+ .toList();
605
591
- final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
592
- final message = (body['messages'] as List).single as Map<String, dynamic>;
593
- expect(message['content'], 'Continue without the missing image');
594
- expect(responses.last.type, ChatResponseType.done);
595
- });
606
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
607
+ final message =
608
+ (body['messages'] as List).single as Map<String, dynamic>;
609
+ expect(message['content'], 'Continue without the missing image');
610
+ expect(responses.last.type, ChatResponseType.done);
611
+ },
612
+ );
613
});
614
615
group('OpenRouter attachment formatting', () {
@@ -611,9 +628,21 @@ void main() {
628
});
629
630
test('sends image attachments as base64 data-URL content parts', () async {
614
- final pngBytes = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02];
615
- final png = await File('${tempDirectory.path}/photo.png')
616
- .writeAsBytes(pngBytes);
631
+ final pngBytes = [
632
+ 0x89,
633
+ 0x50,
634
+ 0x4e,
635
+ 0x47,
636
+ 0x0d,
637
+ 0x0a,
638
+ 0x1a,
639
+ 0x0a,
640
+ 0x01,
641
+ 0x02,
642
+ ];
643
+ final png = await File(
644
+ '${tempDirectory.path}/photo.png',
645
+ ).writeAsBytes(pngBytes);
646
final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
647
final service = OpenRouterChatService(
648
Dio()..interceptors.add(interceptor),
@@ -642,15 +671,10 @@ void main() {
671
final content = (body['messages'] as List).single['content'] as List;
672
expect(content, hasLength(2));
673
expect(content[0], {'type': 'text', 'text': 'What is shown?'});
645
- expect(
646
- content[1],
647
- {
648
- 'type': 'image_url',
649
- 'image_url': {
650
- 'url': 'data:image/png;base64,${base64Encode(pngBytes)}',
651
- },
652
- },
653
- );
674
+ expect(content[1], {
675
+ 'type': 'image_url',
676
+ 'image_url': {'url': 'data:image/png;base64,${base64Encode(pngBytes)}'},
677
+ });
678
expect(responses.last.type, ChatResponseType.done);
679
});
680
@@ -691,79 +715,85 @@ void main() {
715
});
716
717
group('OpenAI-compatible & OpenRouter assistant prefill handling', () {
694
- test('strips trailing empty assistant when not continuing generation', () async {
695
- final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
696
- final service = OpenAICompatibleChatService(
697
- Dio()..interceptors.add(interceptor),
698
- );
718
+ test(
719
+ 'strips trailing empty assistant when not continuing generation',
720
+ () async {
721
+ final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
722
+ final service = OpenAICompatibleChatService(
723
+ Dio()..interceptors.add(interceptor),
724
+ );
725
700
- await service
701
- .sendMessage(
702
- server: _openAiTestServer(),
703
- modelId: 'text-model',
704
- messages: [
705
- Message(
706
- id: 'u1',
707
- conversationId: 'c',
708
- role: MessageRole.user,
709
- content: 'hi',
710
- createdAt: DateTime.now(),
711
- ),
712
- Message(
713
- id: 'a1',
714
- conversationId: 'c',
715
- role: MessageRole.assistant,
716
- content: '',
717
- createdAt: DateTime.now(),
718
- ),
719
- ],
720
- params: ChatParameters.defaults(),
721
- )
722
- .toList();
726
+ await service
727
+ .sendMessage(
728
+ server: _openAiTestServer(),
729
+ modelId: 'text-model',
730
+ messages: [
731
+ Message(
732
+ id: 'u1',
733
+ conversationId: 'c',
734
+ role: MessageRole.user,
735
+ content: 'hi',
736
+ createdAt: DateTime.now(),
737
+ ),
738
+ Message(
739
+ id: 'a1',
740
+ conversationId: 'c',
741
+ role: MessageRole.assistant,
742
+ content: '',
743
+ createdAt: DateTime.now(),
744
+ ),
745
+ ],
746
+ params: ChatParameters.defaults(),
747
+ )
748
+ .toList();
749
724
- final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
725
- final msgs = (body['messages'] as List).cast<Map<String, dynamic>>();
726
- expect(msgs, hasLength(1));
727
- expect(msgs.last['role'], 'user');
728
- });
750
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
751
+ final msgs = (body['messages'] as List).cast<Map<String, dynamic>>();
752
+ expect(msgs, hasLength(1));
753
+ expect(msgs.last['role'], 'user');
754
+ },
755
+ );
756
730
- test('keeps trailing assistant prefill when continuing generation (OpenRouter)', () async {
731
- final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
732
- final service = OpenRouterChatService(
733
- Dio()..interceptors.add(interceptor),
734
- );
757
+ test(
758
+ 'keeps trailing assistant prefill when continuing generation (OpenRouter)',
759
+ () async {
760
+ final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
761
+ final service = OpenRouterChatService(
762
+ Dio()..interceptors.add(interceptor),
763
+ );
764
736
- await service
737
- .sendMessage(
738
- server: _openRouterTestServer(),
739
- modelId: 'text-model',
740
- messages: [
741
- Message(
742
- id: 'u1',
743
- conversationId: 'c',
744
- role: MessageRole.user,
745
- content: 'hi',
746
- createdAt: DateTime.now(),
747
- ),
748
- Message(
749
- id: 'a1',
750
- conversationId: 'c',
751
- role: MessageRole.assistant,
752
- content: 'Sure, ',
753
- createdAt: DateTime.now(),
754
- ),
755
- ],
756
- params: ChatParameters.defaults(),
757
- continueGeneration: true,
758
- )
759
- .toList();
765
+ await service
766
+ .sendMessage(
767
+ server: _openRouterTestServer(),
768
+ modelId: 'text-model',
769
+ messages: [
770
+ Message(
771
+ id: 'u1',
772
+ conversationId: 'c',
773
+ role: MessageRole.user,
774
+ content: 'hi',
775
+ createdAt: DateTime.now(),
776
+ ),
777
+ Message(
778
+ id: 'a1',
779
+ conversationId: 'c',
780
+ role: MessageRole.assistant,
781
+ content: 'Sure, ',
782
+ createdAt: DateTime.now(),
783
+ ),
784
+ ],
785
+ params: ChatParameters.defaults(),
786
+ continueGeneration: true,
787
+ )
788
+ .toList();
789
761
- final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
762
- final msgs = (body['messages'] as List).cast<Map<String, dynamic>>();
763
- expect(msgs, hasLength(2));
764
- expect(msgs.last['role'], 'assistant');
765
- expect(msgs.last['content'], 'Sure, ');
766
- });
790
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
791
+ final msgs = (body['messages'] as List).cast<Map<String, dynamic>>();
792
+ expect(msgs, hasLength(2));
793
+ expect(msgs.last['role'], 'assistant');
794
+ expect(msgs.last['content'], 'Sure, ');
795
+ },
796
+ );
797
798
test('strips trailing empty assistant on OpenRouter too', () async {
799
final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
test/features/chat/tools/lm_studio_tool_adapter_test.dart
+3
-1
@@ -19,7 +19,9 @@ void main() {
19
test('tracks lifecycle across start-arguments-success events', () {
20
final adapter = LmStudioToolAdapter();
21
adapter.consumeEvent('tool_call.start', {'tool': 'calc.add'});
22
- adapter.consumeEvent('tool_call.arguments', {'arguments': {'a': 1, 'b': 2}});
22
+ adapter.consumeEvent('tool_call.arguments', {
23
+ 'arguments': {'a': 1, 'b': 2},
24
+ });
25
adapter.consumeEvent('tool_call.success', {'output': '3'});
26
final calls = adapter.takeServerExecutedCalls();
27
expect(calls.length, 1);
test/features/chat/tools/openai_tool_adapter_test.dart
+4
-1
@@ -28,7 +28,10 @@ void main() {
28
{
29
'delta': {
30
'tool_calls': [
31
- {'index': 0, 'function': {'arguments': '{"a":1,"b":2}'}},
31
+ {
32
+ 'index': 0,
33
+ 'function': {'arguments': '{"a":1,"b":2}'},
34
+ },
35
],
36
},
37
},
test/features/chat/tools/tool_execution_loop_test.dart
+67
-25
@@ -11,7 +11,9 @@ class FakeToolAdapter implements ToolTransportAdapter {
11
int callCount = 0;
12
13
FakeToolAdapter.singleToolCall(String name, Map<String, dynamic> args)
14
- : callsToReturn = [ParsedToolCall(id: 'call_1', name: name, arguments: args)];
14
+ : callsToReturn = [
15
+ ParsedToolCall(id: 'call_1', name: name, arguments: args),
16
+ ];
17
18
@override
19
Map<String, dynamic> buildToolDefinitionPayload(List<ToolDefinition> tools) {
@@ -51,7 +53,10 @@ void main() {
53
initialUserMessage: 'add 1 and 2',
54
assistantContent: 'The result is 3',
55
);
54
- expect(result.events.any((e) => e.status == ToolEventStatus.completed), true);
56
+ expect(
57
+ result.events.any((e) => e.status == ToolEventStatus.completed),
58
+ true,
59
+ );
60
expect(result.finalAssistantContent, 'The result is 3');
61
});
62
@@ -62,11 +67,17 @@ void main() {
67
);
68
69
final result = await loop.run(initialUserMessage: 'do something');
65
- expect(result.events.any((e) => e.status == ToolEventStatus.failed), true);
70
+ expect(
71
+ result.events.any((e) => e.status == ToolEventStatus.failed),
72
+ true,
73
+ );
74
});
75
76
test('respects maxIterations', () async {
69
- final adapter = FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2});
77
+ final adapter = FakeToolAdapter.singleToolCall('calc.add', {
78
+ 'a': 1,
79
+ 'b': 2,
80
+ });
81
final loop = ToolExecutionLoop(
82
adapter: adapter,
83
registry: ToolRegistry(providers: [BuiltInToolProvider()]),
@@ -74,7 +85,10 @@ void main() {
85
);
86
87
final result = await loop.run(initialUserMessage: 'add');
77
- expect(result.events.any((e) => e.status == ToolEventStatus.completed), true);
88
+ expect(
89
+ result.events.any((e) => e.status == ToolEventStatus.completed),
90
+ true,
91
+ );
92
expect(result.toolCallCount, greaterThan(0));
93
});
94
@@ -96,8 +110,14 @@ void main() {
110
);
111
112
final result = await loop.run(initialUserMessage: 'add');
99
- expect(result.events.any((e) => e.status == ToolEventStatus.approved), true);
100
- expect(result.events.any((e) => e.status == ToolEventStatus.completed), true);
113
+ expect(
114
+ result.events.any((e) => e.status == ToolEventStatus.approved),
115
+ true,
116
+ );
117
+ expect(
118
+ result.events.any((e) => e.status == ToolEventStatus.completed),
119
+ true,
120
+ );
121
});
122
123
test('rejects tool when approval callback returns false', () async {
@@ -108,25 +128,37 @@ void main() {
128
);
129
130
final result = await loop.run(initialUserMessage: 'add');
111
- expect(result.events.any((e) => e.status == ToolEventStatus.rejected), true);
112
- expect(result.events.any((e) => e.status == ToolEventStatus.completed), false);
113
- });
114
-
115
- test('loop runs with tool calls and returns events with assistant content', () async {
116
- final adapter = FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2});
117
- final loop = ToolExecutionLoop(
118
- adapter: adapter,
119
- registry: ToolRegistry(providers: [BuiltInToolProvider()]),
131
+ expect(
132
+ result.events.any((e) => e.status == ToolEventStatus.rejected),
133
+ true,
134
);
121
-
122
- final result = await loop.run(
123
- initialUserMessage: 'hello',
124
- assistantContent: 'Hi there!',
135
+ expect(
136
+ result.events.any((e) => e.status == ToolEventStatus.completed),
137
+ false,
138
);
126
- expect(result.finalAssistantContent, 'Hi there!');
127
- expect(result.events, isNotEmpty);
139
});
140
141
+ test(
142
+ 'loop runs with tool calls and returns events with assistant content',
143
+ () async {
144
+ final adapter = FakeToolAdapter.singleToolCall('calc.add', {
145
+ 'a': 1,
146
+ 'b': 2,
147
+ });
148
+ final loop = ToolExecutionLoop(
149
+ adapter: adapter,
150
+ registry: ToolRegistry(providers: [BuiltInToolProvider()]),
151
+ );
152
+
153
+ final result = await loop.run(
154
+ initialUserMessage: 'hello',
155
+ assistantContent: 'Hi there!',
156
+ );
157
+ expect(result.finalAssistantContent, 'Hi there!');
158
+ expect(result.events, isNotEmpty);
159
+ },
160
+ );
161
+
162
test('loop processes preParsedCalls when provided', () async {
163
final loop = ToolExecutionLoop(
164
adapter: FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2}),
@@ -136,15 +168,25 @@ void main() {
168
final result = await loop.run(
169
initialUserMessage: 'add',
170
preParsedCalls: [
139
- const ParsedToolCall(id: 'pc_1', name: 'calc.add', arguments: {'a': 1, 'b': 2}),
171
+ const ParsedToolCall(
172
+ id: 'pc_1',
173
+ name: 'calc.add',
174
+ arguments: {'a': 1, 'b': 2},
175
+ ),
176
],
177
);
142
- expect(result.events.any((e) => e.status == ToolEventStatus.completed), true);
178
+ expect(
179
+ result.events.any((e) => e.status == ToolEventStatus.completed),
180
+ true,
181
+ );
182
expect(result.toolCallCount, 1);
183
});
184
185
test('loop returns empty result when no tool calls', () async {
147
- final adapter = FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2});
186
+ final adapter = FakeToolAdapter.singleToolCall('calc.add', {
187
+ 'a': 1,
188
+ 'b': 2,
189
+ });
190
final loop = ToolExecutionLoop(
191
adapter: adapter,
192
registry: ToolRegistry(providers: [BuiltInToolProvider()]),
test/features/issue_crash_fixes_test.dart
+573
-392
@@ -64,170 +64,196 @@ void main() {
64
});
65
66
group('Issue #60 & #63: Server API Error Handling', () {
67
- test('fetchModels throws clean formatted error on 401 unauthorized with top-level message', () async {
68
- final dio = Dio();
69
- dio.httpClientAdapter = _MockHttpClientAdapter((options) {
70
- return ResponseBody.fromString(
71
- '{"message":"Invalid API key or unauthorized.","type":"authentication_error"}',
72
- 401,
73
- headers: {
74
- Headers.contentTypeHeader: [Headers.jsonContentType],
75
- },
67
+ test(
68
+ 'fetchModels throws clean formatted error on 401 unauthorized with top-level message',
69
+ () async {
70
+ final dio = Dio();
71
+ dio.httpClientAdapter = _MockHttpClientAdapter((options) {
72
+ return ResponseBody.fromString(
73
+ '{"message":"Invalid API key or unauthorized.","type":"authentication_error"}',
74
+ 401,
75
+ headers: {
76
+ Headers.contentTypeHeader: [Headers.jsonContentType],
77
+ },
78
+ );
79
+ });
80
+
81
+ final apiService = ServerApiService(dio);
82
+ final server = Server(
83
+ id: 'test-server',
84
+ name: 'OpenRouter',
85
+ host: 'openrouter.ai',
86
+ port: 443,
87
+ type: ServerType.openRouter,
88
+ apiKey: 'invalid-key',
89
+ createdAt: now,
90
+ lastConnectedAt: now,
91
);
77
- });
78
-
79
- final apiService = ServerApiService(dio);
80
- final server = Server(
81
- id: 'test-server',
82
- name: 'OpenRouter',
83
- host: 'openrouter.ai',
84
- port: 443,
85
- type: ServerType.openRouter,
86
- apiKey: 'invalid-key',
87
- createdAt: now,
88
- lastConnectedAt: now,
89
- );
90
-
91
- expect(
92
- () => apiService.fetchModels(server),
93
- throwsA(
94
- predicate((e) =>
95
- e is Exception &&
96
- e.toString().contains('Failed to fetch models: authentication_error: Invalid API key or unauthorized.')),
97
- ),
98
- );
99
- });
92
101
- test('fetchModels throws clean formatted error on 500 error payload', () async {
102
- final dio = Dio();
103
- dio.httpClientAdapter = _MockHttpClientAdapter((options) {
104
- return ResponseBody.fromString(
105
- '{"error":{"code":500,"message":"Internal engine crash"}}',
106
- 500,
107
- headers: {
108
- Headers.contentTypeHeader: [Headers.jsonContentType],
109
- },
93
+ expect(
94
+ () => apiService.fetchModels(server),
95
+ throwsA(
96
+ predicate(
97
+ (e) =>
98
+ e is Exception &&
99
+ e.toString().contains(
100
+ 'Failed to fetch models: authentication_error: Invalid API key or unauthorized.',
101
+ ),
102
+ ),
103
+ ),
104
);
111
- });
112
-
113
- final apiService = ServerApiService(dio);
114
- final server = Server(
115
- id: 'test-server-500',
116
- name: 'Local Server',
117
- host: 'localhost',
118
- port: 1234,
119
- type: ServerType.openAICompatible,
120
- createdAt: now,
121
- lastConnectedAt: now,
122
- );
123
-
124
- expect(
125
- () => apiService.fetchModels(server),
126
- throwsA(
127
- predicate((e) =>
128
- e is Exception &&
129
- e.toString().contains('Failed to fetch models: Error 500: Internal engine crash')),
130
- ),
131
- );
132
- });
133
-
134
- test('loadModelWithInstanceId throws clean formatted error on top-level API error map', () async {
135
- final dio = Dio();
136
- dio.httpClientAdapter = _MockHttpClientAdapter((options) {
137
- return ResponseBody.fromString(
138
- '{"message":"Model not found on disk","type":"not_found"}',
139
- 404,
140
- headers: {
141
- Headers.contentTypeHeader: [Headers.jsonContentType],
142
- },
105
+ },
106
+ );
107
+
108
+ test(
109
+ 'fetchModels throws clean formatted error on 500 error payload',
110
+ () async {
111
+ final dio = Dio();
112
+ dio.httpClientAdapter = _MockHttpClientAdapter((options) {
113
+ return ResponseBody.fromString(
114
+ '{"error":{"code":500,"message":"Internal engine crash"}}',
115
+ 500,
116
+ headers: {
117
+ Headers.contentTypeHeader: [Headers.jsonContentType],
118
+ },
119
+ );
120
+ });
121
+
122
+ final apiService = ServerApiService(dio);
123
+ final server = Server(
124
+ id: 'test-server-500',
125
+ name: 'Local Server',
126
+ host: 'localhost',
127
+ port: 1234,
128
+ type: ServerType.openAICompatible,
129
+ createdAt: now,
130
+ lastConnectedAt: now,
131
);
144
- });
132
146
- final apiService = ServerApiService(dio);
147
- final server = Server(
148
- id: 'test-server',
149
- name: 'LM Studio',
150
- host: 'localhost',
151
- port: 1234,
152
- type: ServerType.lmStudio,
153
- createdAt: now,
154
- lastConnectedAt: now,
155
- );
156
-
157
- expect(
158
- () => apiService.loadModelWithInstanceId(server, 'missing-model'),
159
- throwsA(
160
- predicate((e) =>
161
- e is Exception &&
162
- e.toString().contains('not_found: Model not found on disk')),
163
- ),
164
- );
165
- });
133
+ expect(
134
+ () => apiService.fetchModels(server),
135
+ throwsA(
136
+ predicate(
137
+ (e) =>
138
+ e is Exception &&
139
+ e.toString().contains(
140
+ 'Failed to fetch models: Error 500: Internal engine crash',
141
+ ),
142
+ ),
143
+ ),
144
+ );
145
+ },
146
+ );
147
+
148
+ test(
149
+ 'loadModelWithInstanceId throws clean formatted error on top-level API error map',
150
+ () async {
151
+ final dio = Dio();
152
+ dio.httpClientAdapter = _MockHttpClientAdapter((options) {
153
+ return ResponseBody.fromString(
154
+ '{"message":"Model not found on disk","type":"not_found"}',
155
+ 404,
156
+ headers: {
157
+ Headers.contentTypeHeader: [Headers.jsonContentType],
158
+ },
159
+ );
160
+ });
161
+
162
+ final apiService = ServerApiService(dio);
163
+ final server = Server(
164
+ id: 'test-server',
165
+ name: 'LM Studio',
166
+ host: 'localhost',
167
+ port: 1234,
168
+ type: ServerType.lmStudio,
169
+ createdAt: now,
170
+ lastConnectedAt: now,
171
+ );
172
167
- testWidgets('showModelInfoSheet gracefully handles availableModelsProvider error', (tester) async {
168
- SharedPreferences.setMockInitialValues({});
169
- final prefs = await SharedPreferences.getInstance();
173
+ expect(
174
+ () => apiService.loadModelWithInstanceId(server, 'missing-model'),
175
+ throwsA(
176
+ predicate(
177
+ (e) =>
178
+ e is Exception &&
179
+ e.toString().contains('not_found: Model not found on disk'),
180
+ ),
181
+ ),
182
+ );
183
+ },
184
+ );
185
171
- final server = Server(
172
- id: 'error-server',
173
- name: 'Error Server',
174
- host: 'localhost',
175
- port: 1234,
176
- type: ServerType.openAICompatible,
177
- createdAt: now,
178
- lastConnectedAt: now,
179
- );
186
+ testWidgets(
187
+ 'showModelInfoSheet gracefully handles availableModelsProvider error',
188
+ (tester) async {
189
+ SharedPreferences.setMockInitialValues({});
190
+ final prefs = await SharedPreferences.getInstance();
191
+
192
+ final server = Server(
193
+ id: 'error-server',
194
+ name: 'Error Server',
195
+ host: 'localhost',
196
+ port: 1234,
197
+ type: ServerType.openAICompatible,
198
+ createdAt: now,
199
+ lastConnectedAt: now,
200
+ );
201
181
- final container = ProviderContainer(
182
- overrides: [
183
- sharedPreferencesProvider.overrideWithValue(prefs),
184
- chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
185
- activeServerProvider.overrideWith(() => _StubActiveServerNotifier(server)),
186
- serversProvider.overrideWith(() => _StubServersNotifier(server)),
187
- availableModelsProvider(server.id).overrideWith(
188
- (ref) async => throw Exception('Server connection failed'),
189
- ),
190
- ],
191
- );
192
- addTearDown(container.dispose);
202
+ final container = ProviderContainer(
203
+ overrides: [
204
+ sharedPreferencesProvider.overrideWithValue(prefs),
205
+ chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
206
+ activeServerProvider.overrideWith(
207
+ () => _StubActiveServerNotifier(server),
208
+ ),
209
+ serversProvider.overrideWith(() => _StubServersNotifier(server)),
210
+ availableModelsProvider(server.id).overrideWith(
211
+ (ref) async => throw Exception('Server connection failed'),
212
+ ),
213
+ ],
214
+ );
215
+ addTearDown(container.dispose);
216
194
- late BuildContext capturedContext;
195
- late WidgetRef capturedRef;
217
+ late BuildContext capturedContext;
218
+ late WidgetRef capturedRef;
219
197
- await tester.pumpWidget(
198
- UncontrolledProviderScope(
199
- container: container,
200
- child: MaterialApp(
201
- localizationsDelegates: AppLocalizations.localizationsDelegates,
202
- supportedLocales: AppLocalizations.supportedLocales,
203
- home: ShadTheme(
204
- data: AppTheme.lightShadTheme,
205
- child: Scaffold(
206
- body: Consumer(
207
- builder: (context, ref, _) {
208
- capturedContext = context;
209
- capturedRef = ref;
210
- return const SizedBox();
211
- },
220
+ await tester.pumpWidget(
221
+ UncontrolledProviderScope(
222
+ container: container,
223
+ child: MaterialApp(
224
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
225
+ supportedLocales: AppLocalizations.supportedLocales,
226
+ home: ShadTheme(
227
+ data: AppTheme.lightShadTheme,
228
+ child: Scaffold(
229
+ body: Consumer(
230
+ builder: (context, ref, _) {
231
+ capturedContext = context;
232
+ capturedRef = ref;
233
+ return const SizedBox();
234
+ },
235
+ ),
236
),
237
),
238
),
239
),
216
- ),
217
- );
218
- await tester.pumpAndSettle();
240
+ );
241
+ await tester.pumpAndSettle();
242
220
- // Trigger showModelInfoSheet and ensure no uncaught exception is thrown
221
- showModelInfoSheet(capturedContext, capturedRef, 'test-model-id');
222
- await tester.pump();
223
- await tester.pump(const Duration(milliseconds: 300));
243
+ // Trigger showModelInfoSheet and ensure no uncaught exception is thrown
244
+ showModelInfoSheet(capturedContext, capturedRef, 'test-model-id');
245
+ await tester.pump();
246
+ await tester.pump(const Duration(milliseconds: 300));
247
225
- expect(tester.takeException(), isNull);
226
- });
248
+ expect(tester.takeException(), isNull);
249
+ },
250
+ );
251
});
252
253
group('Issue #61: ModelList Unmounted Ref Access Guard', () {
230
- testWidgets('does not throw when unmounted while model is loading', (tester) async {
254
+ testWidgets('does not throw when unmounted while model is loading', (
255
+ tester,
256
+ ) async {
257
SharedPreferences.setMockInitialValues({});
258
final prefs = await SharedPreferences.getInstance();
259
@@ -260,11 +286,19 @@ void main() {
286
settingsProvider.overrideWith(_TestSettingsNotifier.new),
287
chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
288
serverApiServiceProvider.overrideWithValue(mockApiService),
263
- activeServerProvider.overrideWith(() => _StubActiveServerNotifier(server)),
289
+ activeServerProvider.overrideWith(
290
+ () => _StubActiveServerNotifier(server),
291
+ ),
292
serversProvider.overrideWith(() => _StubServersNotifier(server)),
265
- connectionStatusProvider.overrideWith(_StubConnectedStatusNotifier.new),
266
- availableModelsProvider(server.id).overrideWith((ref) => Future.value([testModel])),
267
- loadedModelsProvider(server).overrideWith((ref) => Future.value(<String>{})),
293
+ connectionStatusProvider.overrideWith(
294
+ _StubConnectedStatusNotifier.new,
295
+ ),
296
+ availableModelsProvider(
297
+ server.id,
298
+ ).overrideWith((ref) => Future.value([testModel])),
299
+ loadedModelsProvider(
300
+ server,
301
+ ).overrideWith((ref) => Future.value(<String>{})),
302
],
303
);
304
addTearDown(container.dispose);
@@ -296,9 +330,7 @@ void main() {
330
await tester.pumpWidget(
331
UncontrolledProviderScope(
332
container: container,
299
- child: const MaterialApp(
300
- home: Scaffold(body: SizedBox()),
301
- ),
333
+ child: const MaterialApp(home: Scaffold(body: SizedBox())),
334
),
335
);
336
await tester.pumpAndSettle();
@@ -309,289 +341,441 @@ void main() {
341
});
342
343
group('Issue #64: Server Setup Screens Unmounted testConnection Guard', () {
312
- testWidgets('OnboardingServerSetupScreen does not crash when unmounted during testConnection', (tester) async {
313
- SharedPreferences.setMockInitialValues({});
314
- final prefs = await SharedPreferences.getInstance();
315
-
316
- final connectionCompleter = Completer<ResponseBody>();
317
- final mockDio = Dio();
318
- mockDio.httpClientAdapter = _MockHttpClientAdapter((options) {
319
- return connectionCompleter.future;
320
- });
321
- final mockApiService = ServerApiService(mockDio);
322
-
323
- final container = ProviderContainer(
324
- overrides: [
325
- sharedPreferencesProvider.overrideWithValue(prefs),
326
- settingsProvider.overrideWith(_TestSettingsNotifier.new),
327
- chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
328
- serverApiServiceProvider.overrideWithValue(mockApiService),
329
- ],
330
- );
331
- addTearDown(container.dispose);
344
+ testWidgets(
345
+ 'OnboardingServerSetupScreen does not crash when unmounted during testConnection',
346
+ (tester) async {
347
+ SharedPreferences.setMockInitialValues({});
348
+ final prefs = await SharedPreferences.getInstance();
349
+
350
+ final connectionCompleter = Completer<ResponseBody>();
351
+ final mockDio = Dio();
352
+ mockDio.httpClientAdapter = _MockHttpClientAdapter((options) {
353
+ return connectionCompleter.future;
354
+ });
355
+ final mockApiService = ServerApiService(mockDio);
356
+
357
+ final container = ProviderContainer(
358
+ overrides: [
359
+ sharedPreferencesProvider.overrideWithValue(prefs),
360
+ settingsProvider.overrideWith(_TestSettingsNotifier.new),
361
+ chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
362
+ serverApiServiceProvider.overrideWithValue(mockApiService),
363
+ ],
364
+ );
365
+ addTearDown(container.dispose);
366
333
- await tester.pumpWidget(
334
- UncontrolledProviderScope(
335
- container: container,
336
- child: ShadTheme(
337
- data: AppTheme.lightShadTheme,
338
- child: const MaterialApp(
339
- locale: Locale('en'),
340
- localizationsDelegates: AppLocalizations.localizationsDelegates,
341
- supportedLocales: AppLocalizations.supportedLocales,
342
- home: OnboardingServerSetupScreen(
343
- selectedType: ServerType.lmStudio,
367
+ await tester.pumpWidget(
368
+ UncontrolledProviderScope(
369
+ container: container,
370
+ child: ShadTheme(
371
+ data: AppTheme.lightShadTheme,
372
+ child: const MaterialApp(
373
+ locale: Locale('en'),
374
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
375
+ supportedLocales: AppLocalizations.supportedLocales,
376
+ home: OnboardingServerSetupScreen(
377
+ selectedType: ServerType.lmStudio,
378
+ ),
379
),
380
),
381
),
347
- ),
348
- );
349
- await tester.pumpAndSettle();
382
+ );
383
+ await tester.pumpAndSettle();
384
351
- expect(find.text('Test Connection'), findsOneWidget);
385
+ expect(find.text('Test Connection'), findsOneWidget);
386
353
- // Tap Test Connection
354
- await tester.tap(find.text('Test Connection'));
355
- await tester.pump();
387
+ // Tap Test Connection
388
+ await tester.tap(find.text('Test Connection'));
389
+ await tester.pump();
390
357
- // Immediately unmount OnboardingServerSetupScreen while test is in flight
358
- await tester.pumpWidget(
359
- UncontrolledProviderScope(
360
- container: container,
361
- child: const MaterialApp(
362
- home: Scaffold(body: SizedBox()),
391
+ // Immediately unmount OnboardingServerSetupScreen while test is in flight
392
+ await tester.pumpWidget(
393
+ UncontrolledProviderScope(
394
+ container: container,
395
+ child: const MaterialApp(home: Scaffold(body: SizedBox())),
396
),
364
- ),
365
- );
366
- await tester.pump();
367
-
368
- // Now complete the network response
369
- connectionCompleter.complete(
370
- ResponseBody.fromString('{"models":[]}', 200),
371
- );
372
- await tester.pumpAndSettle();
397
+ );
398
+ await tester.pump();
399
374
- // Ensure no Null check operator or setState exception was thrown
375
- expect(tester.takeException(), isNull);
376
- });
400
+ // Now complete the network response
401
+ connectionCompleter.complete(
402
+ ResponseBody.fromString('{"models":[]}', 200),
403
+ );
404
+ await tester.pumpAndSettle();
405
378
- testWidgets('AddServerScreen does not crash when unmounted during testConnection', (tester) async {
379
- SharedPreferences.setMockInitialValues({});
380
- final prefs = await SharedPreferences.getInstance();
406
+ // Ensure no Null check operator or setState exception was thrown
407
+ expect(tester.takeException(), isNull);
408
+ },
409
+ );
410
382
- final connectionCompleter = Completer<ResponseBody>();
383
- final mockDio = Dio();
384
- mockDio.httpClientAdapter = _MockHttpClientAdapter((options) {
385
- return connectionCompleter.future;
386
- });
387
- final mockApiService = ServerApiService(mockDio);
388
-
389
- final container = ProviderContainer(
390
- overrides: [
391
- sharedPreferencesProvider.overrideWithValue(prefs),
392
- settingsProvider.overrideWith(_TestSettingsNotifier.new),
393
- chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
394
- serverApiServiceProvider.overrideWithValue(mockApiService),
395
- ],
396
- );
397
- addTearDown(container.dispose);
411
+ testWidgets(
412
+ 'AddServerScreen does not crash when unmounted during testConnection',
413
+ (tester) async {
414
+ SharedPreferences.setMockInitialValues({});
415
+ final prefs = await SharedPreferences.getInstance();
416
+
417
+ final connectionCompleter = Completer<ResponseBody>();
418
+ final mockDio = Dio();
419
+ mockDio.httpClientAdapter = _MockHttpClientAdapter((options) {
420
+ return connectionCompleter.future;
421
+ });
422
+ final mockApiService = ServerApiService(mockDio);
423
+
424
+ final container = ProviderContainer(
425
+ overrides: [
426
+ sharedPreferencesProvider.overrideWithValue(prefs),
427
+ settingsProvider.overrideWith(_TestSettingsNotifier.new),
428
+ chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
429
+ serverApiServiceProvider.overrideWithValue(mockApiService),
430
+ ],
431
+ );
432
+ addTearDown(container.dispose);
433
399
- await tester.pumpWidget(
400
- UncontrolledProviderScope(
401
- container: container,
402
- child: ShadTheme(
403
- data: AppTheme.lightShadTheme,
404
- child: const MaterialApp(
405
- locale: Locale('en'),
406
- localizationsDelegates: AppLocalizations.localizationsDelegates,
407
- supportedLocales: AppLocalizations.supportedLocales,
408
- home: AddServerScreen(),
434
+ await tester.pumpWidget(
435
+ UncontrolledProviderScope(
436
+ container: container,
437
+ child: ShadTheme(
438
+ data: AppTheme.lightShadTheme,
439
+ child: const MaterialApp(
440
+ locale: Locale('en'),
441
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
442
+ supportedLocales: AppLocalizations.supportedLocales,
443
+ home: AddServerScreen(),
444
+ ),
445
),
446
),
411
- ),
412
- );
413
- await tester.pumpAndSettle();
447
+ );
448
+ await tester.pumpAndSettle();
449
415
- expect(find.text('Test Connection'), findsOneWidget);
450
+ expect(find.text('Test Connection'), findsOneWidget);
451
417
- // Tap Test Connection
418
- await tester.tap(find.text('Test Connection'));
419
- await tester.pump();
452
+ // Tap Test Connection
453
+ await tester.tap(find.text('Test Connection'));
454
+ await tester.pump();
455
421
- // Immediately unmount AddServerScreen while test is in flight
422
- await tester.pumpWidget(
423
- UncontrolledProviderScope(
424
- container: container,
425
- child: const MaterialApp(
426
- home: Scaffold(body: SizedBox()),
456
+ // Immediately unmount AddServerScreen while test is in flight
457
+ await tester.pumpWidget(
458
+ UncontrolledProviderScope(
459
+ container: container,
460
+ child: const MaterialApp(home: Scaffold(body: SizedBox())),
461
),
428
- ),
429
- );
430
- await tester.pump();
462
+ );
463
+ await tester.pump();
464
432
- // Now complete the network response
433
- connectionCompleter.complete(
434
- ResponseBody.fromString('{"models":[]}', 200),
435
- );
436
- await tester.pumpAndSettle();
465
+ // Now complete the network response
466
+ connectionCompleter.complete(
467
+ ResponseBody.fromString('{"models":[]}', 200),
468
+ );
469
+ await tester.pumpAndSettle();
470
438
- // Ensure no Null check operator or setState exception was thrown
439
- expect(tester.takeException(), isNull);
440
- });
471
+ // Ensure no Null check operator or setState exception was thrown
472
+ expect(tester.takeException(), isNull);
473
+ },
474
+ );
475
});
476
477
group('Issue #65: Server Setup Screens Unmounted setState finally-guard', () {
478
testWidgets(
445
- 'OnboardingServerSetupScreen does not crash when testConnection completes after unmount',
446
- (tester) async {
447
- SharedPreferences.setMockInitialValues({});
448
- final prefs = await SharedPreferences.getInstance();
449
-
450
- // Use a Completer so we control when the network response resolves —
451
- // this lets us unmount the widget before the await resumes, then
452
- // resume the future and verify the `finally` setState is a no-op.
453
- final connectionCompleter = Completer<ResponseBody>();
454
- final mockDio = Dio();
455
- mockDio.httpClientAdapter = _MockHttpClientAdapter((options) {
456
- return connectionCompleter.future;
457
- });
458
- final mockApiService = ServerApiService(mockDio);
459
-
460
- final container = ProviderContainer(
461
- overrides: [
462
- sharedPreferencesProvider.overrideWithValue(prefs),
463
- settingsProvider.overrideWith(_TestSettingsNotifier.new),
464
- chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
465
- serverApiServiceProvider.overrideWithValue(mockApiService),
466
- ],
467
- );
468
- addTearDown(container.dispose);
479
+ 'OnboardingServerSetupScreen does not crash when testConnection completes after unmount',
480
+ (tester) async {
481
+ SharedPreferences.setMockInitialValues({});
482
+ final prefs = await SharedPreferences.getInstance();
483
+
484
+ // Use a Completer so we control when the network response resolves —
485
+ // this lets us unmount the widget before the await resumes, then
486
+ // resume the future and verify the `finally` setState is a no-op.
487
+ final connectionCompleter = Completer<ResponseBody>();
488
+ final mockDio = Dio();
489
+ mockDio.httpClientAdapter = _MockHttpClientAdapter((options) {
490
+ return connectionCompleter.future;
491
+ });
492
+ final mockApiService = ServerApiService(mockDio);
493
+
494
+ final container = ProviderContainer(
495
+ overrides: [
496
+ sharedPreferencesProvider.overrideWithValue(prefs),
497
+ settingsProvider.overrideWith(_TestSettingsNotifier.new),
498
+ chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
499
+ serverApiServiceProvider.overrideWithValue(mockApiService),
500
+ ],
501
+ );
502
+ addTearDown(container.dispose);
503
470
- await tester.pumpWidget(
471
- UncontrolledProviderScope(
472
- container: container,
473
- child: ShadTheme(
474
- data: AppTheme.lightShadTheme,
475
- child: const MaterialApp(
476
- locale: Locale('en'),
477
- localizationsDelegates: AppLocalizations.localizationsDelegates,
478
- supportedLocales: AppLocalizations.supportedLocales,
479
- home: OnboardingServerSetupScreen(
480
- selectedType: ServerType.lmStudio,
504
+ await tester.pumpWidget(
505
+ UncontrolledProviderScope(
506
+ container: container,
507
+ child: ShadTheme(
508
+ data: AppTheme.lightShadTheme,
509
+ child: const MaterialApp(
510
+ locale: Locale('en'),
511
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
512
+ supportedLocales: AppLocalizations.supportedLocales,
513
+ home: OnboardingServerSetupScreen(
514
+ selectedType: ServerType.lmStudio,
515
+ ),
516
),
517
),
518
),
484
- ),
485
- );
486
- await tester.pumpAndSettle();
519
+ );
520
+ await tester.pumpAndSettle();
521
488
- expect(find.text('Test Connection'), findsOneWidget);
522
+ expect(find.text('Test Connection'), findsOneWidget);
523
490
- // Kick off the test connection (this enters the try block and awaits).
491
- await tester.tap(find.text('Test Connection'));
492
- await tester.pump();
524
+ // Kick off the test connection (this enters the try block and awaits).
525
+ await tester.tap(find.text('Test Connection'));
526
+ await tester.pump();
527
494
- // Unmount the screen *while* the await is pending. This simulates a
495
- // user navigating away (or any other reason for the widget to be
496
- // disposed) before the network response arrives.
497
- await tester.pumpWidget(
498
- UncontrolledProviderScope(
499
- container: container,
500
- child: const MaterialApp(
501
- home: Scaffold(body: SizedBox()),
528
+ // Unmount the screen *while* the await is pending. This simulates a
529
+ // user navigating away (or any other reason for the widget to be
530
+ // disposed) before the network response arrives.
531
+ await tester.pumpWidget(
532
+ UncontrolledProviderScope(
533
+ container: container,
534
+ child: const MaterialApp(home: Scaffold(body: SizedBox())),
535
),
503
- ),
504
- );
505
- await tester.pump();
506
-
507
- // Now resume the future — this should run the remaining setState
508
- // calls in the try, catch, and finally blocks. Without the mounted
509
- // guards this would crash with
510
- // "Null check operator used on a null value" at
511
- // package:flutter/src/widgets/framework.dart:1219 from
512
- // `_element!.markNeedsBuild()` (issue #65).
513
- connectionCompleter.complete(
514
- ResponseBody.fromString('{"models":[]}', 200),
515
- );
516
- await tester.pumpAndSettle();
536
+ );
537
+ await tester.pump();
538
+
539
+ // Now resume the future — this should run the remaining setState
540
+ // calls in the try, catch, and finally blocks. Without the mounted
541
+ // guards this would crash with
542
+ // "Null check operator used on a null value" at
543
+ // package:flutter/src/widgets/framework.dart:1219 from
544
+ // `_element!.markNeedsBuild()` (issue #65).
545
+ connectionCompleter.complete(
546
+ ResponseBody.fromString('{"models":[]}', 200),
547
+ );
548
+ await tester.pumpAndSettle();
549
518
- expect(tester.takeException(), isNull);
519
- });
550
+ expect(tester.takeException(), isNull);
551
+ },
552
+ );
553
554
testWidgets(
522
- 'OnboardingServerSetupScreen does not crash when testConnection fails after unmount',
523
- (tester) async {
524
- SharedPreferences.setMockInitialValues({});
525
- final prefs = await SharedPreferences.getInstance();
555
+ 'OnboardingServerSetupScreen does not crash when testConnection fails after unmount',
556
+ (tester) async {
557
+ SharedPreferences.setMockInitialValues({});
558
+ final prefs = await SharedPreferences.getInstance();
559
+
560
+ // Completer that returns a 500 response — exercises the failure
561
+ // branch (isConnected == false) + the `finally` block while the
562
+ // widget is already unmounted.
563
+ final connectionCompleter = Completer<ResponseBody>();
564
+ final mockDio = Dio();
565
+ mockDio.httpClientAdapter = _MockHttpClientAdapter((options) {
566
+ return connectionCompleter.future;
567
+ });
568
+ final mockApiService = ServerApiService(mockDio);
569
+
570
+ final container = ProviderContainer(
571
+ overrides: [
572
+ sharedPreferencesProvider.overrideWithValue(prefs),
573
+ settingsProvider.overrideWith(_TestSettingsNotifier.new),
574
+ chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
575
+ serverApiServiceProvider.overrideWithValue(mockApiService),
576
+ ],
577
+ );
578
+ addTearDown(container.dispose);
579
527
- // Completer that returns a 500 response — exercises the failure
528
- // branch (isConnected == false) + the `finally` block while the
529
- // widget is already unmounted.
530
- final connectionCompleter = Completer<ResponseBody>();
531
- final mockDio = Dio();
532
- mockDio.httpClientAdapter = _MockHttpClientAdapter((options) {
533
- return connectionCompleter.future;
534
- });
535
- final mockApiService = ServerApiService(mockDio);
580
+ await tester.pumpWidget(
581
+ UncontrolledProviderScope(
582
+ container: container,
583
+ child: ShadTheme(
584
+ data: AppTheme.lightShadTheme,
585
+ child: const MaterialApp(
586
+ locale: Locale('en'),
587
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
588
+ supportedLocales: AppLocalizations.supportedLocales,
589
+ home: OnboardingServerSetupScreen(
590
+ selectedType: ServerType.openAICompatible,
591
+ ),
592
+ ),
593
+ ),
594
+ ),
595
+ );
596
+ await tester.pumpAndSettle();
597
537
- final container = ProviderContainer(
538
- overrides: [
539
- sharedPreferencesProvider.overrideWithValue(prefs),
540
- settingsProvider.overrideWith(_TestSettingsNotifier.new),
541
- chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
542
- serverApiServiceProvider.overrideWithValue(mockApiService),
543
- ],
544
- );
545
- addTearDown(container.dispose);
598
+ await tester.tap(find.text('Test Connection'));
599
+ await tester.pump();
600
547
- await tester.pumpWidget(
548
- UncontrolledProviderScope(
549
- container: container,
550
- child: ShadTheme(
551
- data: AppTheme.lightShadTheme,
552
- child: const MaterialApp(
553
- locale: Locale('en'),
554
- localizationsDelegates: AppLocalizations.localizationsDelegates,
555
- supportedLocales: AppLocalizations.supportedLocales,
556
- home: OnboardingServerSetupScreen(
557
- selectedType: ServerType.openAICompatible,
558
- ),
601
+ // Unmount before completing the in-flight request.
602
+ await tester.pumpWidget(
603
+ UncontrolledProviderScope(
604
+ container: container,
605
+ child: const MaterialApp(home: Scaffold(body: SizedBox())),
606
+ ),
607
+ );
608
+ await tester.pump();
609
+
610
+ // Complete with a 500 response so the testConnection method returns
611
+ // false (no exception thrown). This drives both the try branch
612
+ // (isConnected == false) and the finally branch (sets
613
+ // _isTesting = false) on the now-disposed state. Without the
614
+ // mounted guard, the finally setState would crash with
615
+ // "Null check operator used on a null value" (#65).
616
+ connectionCompleter.complete(
617
+ ResponseBody.fromString('{"error":"server error"}', 500),
618
+ );
619
+ await tester.pumpAndSettle();
620
+
621
+ expect(tester.takeException(), isNull);
622
+ },
623
+ );
624
+ });
625
+
626
+ group('Issue #69: Riverpod Ref Disposed Async Race Protection', () {
627
+ test(
628
+ 'ConnectionStatusNotifier does not throw or access disposed Ref when container is disposed during testConnection',
629
+ () async {
630
+ final completer = Completer<ResponseBody>();
631
+ final dio = Dio()
632
+ ..httpClientAdapter = _MockHttpClientAdapter((_) => completer.future);
633
+ final apiService = ServerApiService(dio);
634
+
635
+ final server = Server(
636
+ id: 'server-69-a',
637
+ name: 'Ollama Test',
638
+ host: '127.0.0.1',
639
+ port: 11434,
640
+ type: ServerType.ollama,
641
+ createdAt: now,
642
+ lastConnectedAt: now,
643
+ );
644
+
645
+ final container = ProviderContainer(
646
+ overrides: [
647
+ serverApiServiceProvider.overrideWithValue(apiService),
648
+ activeServerProvider.overrideWith(
649
+ () => _StubActiveServerNotifier(server),
650
),
651
+ serversProvider.overrideWith(() => _StubServersNotifier(server)),
652
+ ],
653
+ );
654
+
655
+ // Read connection status to trigger build and microtask
656
+ expect(
657
+ container.read(connectionStatusProvider),
658
+ ConnectionStatus.checking,
659
+ );
660
+
661
+ // Allow microtask to run
662
+ await Future<void>.delayed(Duration.zero);
663
+
664
+ // Dispose container while connection test is in flight
665
+ container.dispose();
666
+
667
+ // Complete the network request after disposal
668
+ completer.complete(
669
+ ResponseBody.fromString(
670
+ '{"version":"0.1.0"}',
671
+ 200,
672
+ headers: {
673
+ Headers.contentTypeHeader: [Headers.jsonContentType],
674
+ },
675
),
561
- ),
562
- );
563
- await tester.pumpAndSettle();
676
+ );
677
565
- await tester.tap(find.text('Test Connection'));
566
- await tester.pump();
678
+ // Allow any microtasks / async continuations to run without throwing StateError
679
+ await Future<void>.delayed(const Duration(milliseconds: 50));
680
+ },
681
+ );
682
+
683
+ test(
684
+ 'ConnectionStatusNotifier ignores stale older generation when server switches rapidly',
685
+ () async {
686
+ final completer1 = Completer<ResponseBody>();
687
+ final completer2 = Completer<ResponseBody>();
688
+
689
+ var callCount = 0;
690
+ final dio = Dio()
691
+ ..httpClientAdapter = _MockHttpClientAdapter((options) {
692
+ callCount++;
693
+ if (callCount == 1) return completer1.future;
694
+ return completer2.future;
695
+ });
696
+ final apiService = ServerApiService(dio);
697
+
698
+ final serverA = Server(
699
+ id: 'server-69-a',
700
+ name: 'Server A',
701
+ host: '127.0.0.1',
702
+ port: 11434,
703
+ type: ServerType.ollama,
704
+ createdAt: now,
705
+ lastConnectedAt: now,
706
+ );
707
+ final serverB = Server(
708
+ id: 'server-69-b',
709
+ name: 'Server B',
710
+ host: '127.0.0.1',
711
+ port: 1234,
712
+ type: ServerType.lmStudio,
713
+ createdAt: now,
714
+ lastConnectedAt: now,
715
+ );
716
568
- // Unmount before completing the in-flight request.
569
- await tester.pumpWidget(
570
- UncontrolledProviderScope(
571
- container: container,
572
- child: const MaterialApp(
573
- home: Scaffold(body: SizedBox()),
717
+ final activeNotifier = _MutableActiveServerNotifier(serverA);
718
+
719
+ final container = ProviderContainer(
720
+ overrides: [
721
+ serverApiServiceProvider.overrideWithValue(apiService),
722
+ activeServerProvider.overrideWith(() => activeNotifier),
723
+ serversProvider.overrideWith(() => _StubServersNotifier(serverA)),
724
+ ],
725
+ );
726
+ addTearDown(container.dispose);
727
+
728
+ expect(
729
+ container.read(connectionStatusProvider),
730
+ ConnectionStatus.checking,
731
+ );
732
+ await Future<void>.delayed(Duration.zero);
733
+
734
+ // Switch active server to Server B before Server A's check completes
735
+ activeNotifier.setServer(serverB);
736
+ container.read(connectionStatusProvider);
737
+ await Future<void>.delayed(Duration.zero);
738
+
739
+ // Server A completes as disconnected
740
+ completer1.complete(ResponseBody.fromString('error', 500));
741
+ await Future<void>.delayed(const Duration(milliseconds: 10));
742
+
743
+ // Server B completes as connected
744
+ completer2.complete(
745
+ ResponseBody.fromString(
746
+ '{"data":[]}',
747
+ 200,
748
+ headers: {
749
+ Headers.contentTypeHeader: [Headers.jsonContentType],
750
+ },
751
),
575
- ),
576
- );
577
- await tester.pump();
578
-
579
- // Complete with a 500 response so the testConnection method returns
580
- // false (no exception thrown). This drives both the try branch
581
- // (isConnected == false) and the finally branch (sets
582
- // _isTesting = false) on the now-disposed state. Without the
583
- // mounted guard, the finally setState would crash with
584
- // "Null check operator used on a null value" (#65).
585
- connectionCompleter.complete(
586
- ResponseBody.fromString('{"error":"server error"}', 500),
587
- );
588
- await tester.pumpAndSettle();
752
+ );
753
+ await Future<void>.delayed(const Duration(milliseconds: 10));
754
590
- expect(tester.takeException(), isNull);
591
- });
755
+ // State should reflect Server B's connected status, not Server A's error
756
+ expect(
757
+ container.read(connectionStatusProvider),
758
+ ConnectionStatus.connected,
759
+ );
760
+ },
761
+ );
762
});
763
}
764
765
+class _MutableActiveServerNotifier extends ActiveServerNotifier {
766
+ _MutableActiveServerNotifier(this._server);
767
+
768
+ Server? _server;
769
+
770
+ @override
771
+ Server? build() => _server;
772
+
773
+ void setServer(Server? server) {
774
+ _server = server;
775
+ state = server;
776
+ }
777
+}
778
+
779
class _StubActiveServerNotifier extends ActiveServerNotifier {
780
_StubActiveServerNotifier(this.server);
781
@@ -635,8 +819,5 @@ class _MockHttpClientAdapter implements HttpClientAdapter {
819
820
class _TestSettingsNotifier extends SettingsNotifier {
821
@override
638
- AppSettings build() => AppSettings(
639
- unloadModelsBeforeLoad: false,
640
- );
822
+ AppSettings build() => AppSettings(unloadModelsBeforeLoad: false);
823
}
642
-
test/features/models/model_metadata_providers_test.dart
+26
-21
@@ -11,9 +11,7 @@ void main() {
11
final prefs = await SharedPreferences.getInstance();
12
13
final container = ProviderContainer(
14
- overrides: [
15
- sharedPreferencesProvider.overrideWithValue(prefs),
16
- ],
14
+ overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
15
);
16
addTearDown(container.dispose);
17
@@ -23,31 +21,38 @@ void main() {
21
expect(container.read(modelMetadataProvider), isEmpty);
22
23
await notifier.toggleFavorite('model-1');
26
- expect(container.read(modelMetadataProvider)['model-1']?.isFavorite, isTrue);
24
+ expect(
25
+ container.read(modelMetadataProvider)['model-1']?.isFavorite,
26
+ isTrue,
27
+ );
28
29
await notifier.setNote('model-1', 'My favorite model');
29
- expect(container.read(modelMetadataProvider)['model-1']?.note, 'My favorite model');
30
+ expect(
31
+ container.read(modelMetadataProvider)['model-1']?.note,
32
+ 'My favorite model',
33
+ );
34
});
35
32
- test('does not throw or access disposed Ref when disposed during async write', () async {
33
- SharedPreferences.setMockInitialValues({});
34
- final prefs = await SharedPreferences.getInstance();
36
+ test(
37
+ 'does not throw or access disposed Ref when disposed during async write',
38
+ () async {
39
+ SharedPreferences.setMockInitialValues({});
40
+ final prefs = await SharedPreferences.getInstance();
41
36
- final container = ProviderContainer(
37
- overrides: [
38
- sharedPreferencesProvider.overrideWithValue(prefs),
39
- ],
40
- );
42
+ final container = ProviderContainer(
43
+ overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
44
+ );
45
42
- final notifier = container.read(modelMetadataProvider.notifier);
43
- notifier.loadForServer('server-1');
46
+ final notifier = container.read(modelMetadataProvider.notifier);
47
+ notifier.loadForServer('server-1');
48
45
- // Start async toggleFavorite and dispose container immediately
46
- final future = notifier.toggleFavorite('model-1');
47
- container.dispose();
49
+ // Start async toggleFavorite and dispose container immediately
50
+ final future = notifier.toggleFavorite('model-1');
51
+ container.dispose();
52
49
- // Completing without throwing StateError confirms ref.mounted guard works
50
- await expectLater(future, completes);
51
- });
53
+ // Completing without throwing StateError confirms ref.mounted guard works
54
+ await expectLater(future, completes);
55
+ },
56
+ );
57
});
58
}
test/features/onboarding/onboarding_language_screen_test.dart
+4
-1
@@ -26,6 +26,9 @@ void main() {
26
expect(l10n.app_name, 'LocalMind');
27
expect(l10n.cancel, 'Annuler');
28
expect(l10n.settings_language, 'Langue');
29
- expect(l10n.onboarding_choose_language, 'Découvrez les langues de LocalMind');
29
+ expect(
30
+ l10n.onboarding_choose_language,
31
+ 'Découvrez les langues de LocalMind',
32
+ );
33
});
34
}
test/features/servers/server_api_service_test.dart
+403
-383
@@ -11,11 +11,9 @@ class TestInterceptor extends Interceptor {
11
12
@override
13
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
14
- handler.resolve(Response(
15
- requestOptions: options,
16
- data: responseData,
17
- statusCode: 200,
18
- ));
14
+ handler.resolve(
15
+ Response(requestOptions: options, data: responseData, statusCode: 200),
16
+ );
17
}
18
}
19
@@ -128,74 +126,78 @@ void main() {
126
);
127
});
128
131
- test('parses models successfully using fallback data array (llama.cpp / OpenAI standard)', () async {
132
- final mockData = {
133
- "object": "list",
134
- "data": [
135
- {
136
- "id": "meta-llama/Llama-3-8B-Instruct",
137
- "object": "model",
138
- "created": 1677610602,
139
- "owned_by": "openai",
140
- "meta": {
141
- "n_ctx_train": 8192,
142
- "n_params": 8000000000,
143
- "size": 4800000000
144
- }
145
- }
146
- ]
147
- };
148
-
149
- final dio = Dio()..interceptors.add(TestInterceptor(mockData));
150
- final service = ServerApiService(dio);
151
-
152
- final models = await service.fetchModels(testServer);
153
-
154
- expect(models, hasLength(1));
155
- final model = models.first;
156
- expect(model.id, "meta-llama/Llama-3-8B-Instruct");
157
- expect(model.name, "Meta Llama/Llama 3 8B Instruct");
158
- expect(model.contextLength, 8192);
159
- expect(model.parameterCount, 8.0); // 8000000000 / 1000000000
160
- expect(model.fileSize, 4800000000);
161
- expect(model.serverType, ServerType.openAICompatible);
162
- expect(model.serverId, testServer.id);
163
- });
164
-
165
- test('parses models successfully using custom models array (LM Studio)', () async {
166
- final mockData = {
167
- "models": [
168
- {
169
- "key": "lmstudio-community/Meta-Llama-3-8B-Instruct-GGUF",
170
- "display_name": "Llama 3 8B Instruct",
171
- "description": "LM Studio test model",
172
- "params_string": "8B",
173
- "max_context_length": 4096,
174
- "size_bytes": 5000000000,
175
- "quantization": {
176
- "name": "Q4_K_M"
129
+ test(
130
+ 'parses models successfully using fallback data array (llama.cpp / OpenAI standard)',
131
+ () async {
132
+ final mockData = {
133
+ "object": "list",
134
+ "data": [
135
+ {
136
+ "id": "meta-llama/Llama-3-8B-Instruct",
137
+ "object": "model",
138
+ "created": 1677610602,
139
+ "owned_by": "openai",
140
+ "meta": {
141
+ "n_ctx_train": 8192,
142
+ "n_params": 8000000000,
143
+ "size": 4800000000,
144
+ },
145
},
178
- "architecture": "llama"
179
- }
180
- ]
181
- };
182
-
183
- final dio = Dio()..interceptors.add(TestInterceptor(mockData));
184
- final service = ServerApiService(dio);
185
-
186
- final models = await service.fetchModels(testServer);
146
+ ],
147
+ };
148
+
149
+ final dio = Dio()..interceptors.add(TestInterceptor(mockData));
150
+ final service = ServerApiService(dio);
151
+
152
+ final models = await service.fetchModels(testServer);
153
+
154
+ expect(models, hasLength(1));
155
+ final model = models.first;
156
+ expect(model.id, "meta-llama/Llama-3-8B-Instruct");
157
+ expect(model.name, "Meta Llama/Llama 3 8B Instruct");
158
+ expect(model.contextLength, 8192);
159
+ expect(model.parameterCount, 8.0); // 8000000000 / 1000000000
160
+ expect(model.fileSize, 4800000000);
161
+ expect(model.serverType, ServerType.openAICompatible);
162
+ expect(model.serverId, testServer.id);
163
+ },
164
+ );
165
188
- expect(models, hasLength(1));
189
- final model = models.first;
190
- expect(model.id, "lmstudio-community/Meta-Llama-3-8B-Instruct-GGUF");
191
- expect(model.name, "Llama 3 8B Instruct");
192
- expect(model.description, "LM Studio test model");
193
- expect(model.parameterCount, 8.0);
194
- expect(model.contextLength, 4096);
195
- expect(model.fileSize, 5000000000);
196
- expect(model.quantization, "Q4_K_M");
197
- expect(model.architecture, "llama");
198
- });
166
+ test(
167
+ 'parses models successfully using custom models array (LM Studio)',
168
+ () async {
169
+ final mockData = {
170
+ "models": [
171
+ {
172
+ "key": "lmstudio-community/Meta-Llama-3-8B-Instruct-GGUF",
173
+ "display_name": "Llama 3 8B Instruct",
174
+ "description": "LM Studio test model",
175
+ "params_string": "8B",
176
+ "max_context_length": 4096,
177
+ "size_bytes": 5000000000,
178
+ "quantization": {"name": "Q4_K_M"},
179
+ "architecture": "llama",
180
+ },
181
+ ],
182
+ };
183
+
184
+ final dio = Dio()..interceptors.add(TestInterceptor(mockData));
185
+ final service = ServerApiService(dio);
186
+
187
+ final models = await service.fetchModels(testServer);
188
+
189
+ expect(models, hasLength(1));
190
+ final model = models.first;
191
+ expect(model.id, "lmstudio-community/Meta-Llama-3-8B-Instruct-GGUF");
192
+ expect(model.name, "Llama 3 8B Instruct");
193
+ expect(model.description, "LM Studio test model");
194
+ expect(model.parameterCount, 8.0);
195
+ expect(model.contextLength, 4096);
196
+ expect(model.fileSize, 5000000000);
197
+ expect(model.quantization, "Q4_K_M");
198
+ expect(model.architecture, "llama");
199
+ },
200
+ );
201
202
test('parses model capabilities from LM Studio models array', () async {
203
final mockData = {
@@ -203,10 +205,7 @@ void main() {
205
{
206
"key": "google/gemma-4-e2b",
207
"display_name": "Gemma 4 E2B",
206
- "capabilities": {
207
- "vision": true,
208
- "trained_for_tool_use": true,
209
- },
208
+ "capabilities": {"vision": true, "trained_for_tool_use": true},
209
},
210
{
211
"key": "mistralai/ministral-3-14b-reasoning",
@@ -223,10 +222,7 @@ void main() {
222
{
223
"key": "mradermacher/kimi-vl-a3b-thinking-2506-i1",
224
"display_name": "Kimi VL A3B Thinking 2506 I1",
226
- "capabilities": {
227
- "vision": false,
228
- "trained_for_tool_use": false,
229
- },
225
+ "capabilities": {"vision": false, "trained_for_tool_use": false},
226
},
227
],
228
};
@@ -251,60 +247,57 @@ void main() {
247
expect(models[2].supportsReasoning, isFalse);
248
});
249
254
- test('handles running models with fallback data array (llama.cpp)', () async {
255
- final mockData = {
256
- "object": "list",
257
- "data": [
258
- {
259
- "id": "active-model",
260
- "object": "model"
261
- }
262
- ]
263
- };
250
+ test(
251
+ 'handles running models with fallback data array (llama.cpp)',
252
+ () async {
253
+ final mockData = {
254
+ "object": "list",
255
+ "data": [
256
+ {"id": "active-model", "object": "model"},
257
+ ],
258
+ };
259
265
- final dio = Dio()..interceptors.add(TestInterceptor(mockData));
266
- final service = ServerApiService(dio);
260
+ final dio = Dio()..interceptors.add(TestInterceptor(mockData));
261
+ final service = ServerApiService(dio);
262
268
- final lmStudioServer = testServer.copyWith(type: ServerType.lmStudio);
269
- final running = await service.fetchRunningModels(lmStudioServer);
263
+ final lmStudioServer = testServer.copyWith(type: ServerType.lmStudio);
264
+ final running = await service.fetchRunningModels(lmStudioServer);
265
271
- expect(running, contains("active-model"));
272
- });
266
+ expect(running, contains("active-model"));
267
+ },
268
+ );
269
274
- test('handles running models with custom models array and loaded instances (LM Studio)', () async {
275
- final mockData = {
276
- "models": [
277
- {
278
- "key": "active-model-lm",
279
- "loaded_instances": [
280
- {"id": "inst_1"}
281
- ]
282
- },
283
- {
284
- "key": "inactive-model-lm",
285
- "loaded_instances": []
286
- }
287
- ]
288
- };
270
+ test(
271
+ 'handles running models with custom models array and loaded instances (LM Studio)',
272
+ () async {
273
+ final mockData = {
274
+ "models": [
275
+ {
276
+ "key": "active-model-lm",
277
+ "loaded_instances": [
278
+ {"id": "inst_1"},
279
+ ],
280
+ },
281
+ {"key": "inactive-model-lm", "loaded_instances": []},
282
+ ],
283
+ };
284
290
- final dio = Dio()..interceptors.add(TestInterceptor(mockData));
291
- final service = ServerApiService(dio);
285
+ final dio = Dio()..interceptors.add(TestInterceptor(mockData));
286
+ final service = ServerApiService(dio);
287
293
- final lmStudioServer = testServer.copyWith(type: ServerType.lmStudio);
294
- final running = await service.fetchRunningModels(lmStudioServer);
288
+ final lmStudioServer = testServer.copyWith(type: ServerType.lmStudio);
289
+ final running = await service.fetchRunningModels(lmStudioServer);
290
296
- expect(running, contains("inst_1"));
297
- expect(running, isNot(contains("inactive-model-lm")));
298
- });
291
+ expect(running, contains("inst_1"));
292
+ expect(running, isNot(contains("inactive-model-lm")));
293
+ },
294
+ );
295
296
test('handles custom string quantization value gracefully', () async {
297
final mockData = {
298
"data": [
303
- {
304
- "id": "model-with-string-quant",
305
- "quantization": "Q5_K_M"
306
- }
307
- ]
299
+ {"id": "model-with-string-quant", "quantization": "Q5_K_M"},
300
+ ],
301
};
302
303
final dio = Dio()..interceptors.add(TestInterceptor(mockData));
@@ -314,117 +307,134 @@ void main() {
307
expect(models.first.quantization, "Q5_K_M");
308
});
309
317
- test('handles actual user llama.cpp endpoint output successfully with Map architecture', () async {
318
- final mockData = {
319
- "data": [
320
- {
321
- "id": "Gemma-4-31B:IQ4_XS",
322
- "aliases": [],
323
- "tags": [],
324
- "object": "model",
325
- "owned_by": "llamacpp",
326
- "created": 1779851034,
327
- "status": {
328
- "value": "unloaded",
329
- "args": [
330
- "D:\\llamacpp\\llama-server.exe",
331
- "--host",
332
- "127.0.0.1",
333
- "--jinja",
334
- "--mlock",
335
- "--no-mmap",
336
- "--port",
337
- "0",
338
- "--alias",
339
- "Gemma-4-31B:IQ4_XS",
340
- "--ctx-size",
341
- "65536",
342
- "--cache-type-k",
343
- "q4_0",
344
- "--cache-type-v",
345
- "q4_0",
346
- "--flash-attn",
347
- "true",
348
- "--model",
349
- "./models/gemma-4-31B-it-llmfan-i1-IQ4_XS.gguf",
350
- "--parallel",
351
- "1",
352
- "--reasoning",
353
- "off"
354
- ],
355
- "preset": "[Gemma-4-31B:IQ4_XS]\njinja = true\nmlock = 1\nmmap = 0\nctx-size = 65536\ncache-type-k = q4_0\ncache-type-v = q4_0\nflash-attn = true\nmodel = ./models/gemma-4-31B-it-llmfan-i1-IQ4_XS.gguf\nparallel = 1\nreasoning = off\n\n"
310
+ test(
311
+ 'handles actual user llama.cpp endpoint output successfully with Map architecture',
312
+ () async {
313
+ final mockData = {
314
+ "data": [
315
+ {
316
+ "id": "Gemma-4-31B:IQ4_XS",
317
+ "aliases": [],
318
+ "tags": [],
319
+ "object": "model",
320
+ "owned_by": "llamacpp",
321
+ "created": 1779851034,
322
+ "status": {
323
+ "value": "unloaded",
324
+ "args": [
325
+ "D:\\llamacpp\\llama-server.exe",
326
+ "--host",
327
+ "127.0.0.1",
328
+ "--jinja",
329
+ "--mlock",
330
+ "--no-mmap",
331
+ "--port",
332
+ "0",
333
+ "--alias",
334
+ "Gemma-4-31B:IQ4_XS",
335
+ "--ctx-size",
336
+ "65536",
337
+ "--cache-type-k",
338
+ "q4_0",
339
+ "--cache-type-v",
340
+ "q4_0",
341
+ "--flash-attn",
342
+ "true",
343
+ "--model",
344
+ "./models/gemma-4-31B-it-llmfan-i1-IQ4_XS.gguf",
345
+ "--parallel",
346
+ "1",
347
+ "--reasoning",
348
+ "off",
349
+ ],
350
+ "preset":
351
+ "[Gemma-4-31B:IQ4_XS]\njinja = true\nmlock = 1\nmmap = 0\nctx-size = 65536\ncache-type-k = q4_0\ncache-type-v = q4_0\nflash-attn = true\nmodel = ./models/gemma-4-31B-it-llmfan-i1-IQ4_XS.gguf\nparallel = 1\nreasoning = off\n\n",
352
+ },
353
+ "architecture": {
354
+ "input_modalities": ["text"],
355
+ "output_modalities": ["text"],
356
+ },
357
},
357
- "architecture": {
358
- "input_modalities": ["text"],
359
- "output_modalities": ["text"]
360
- }
361
- }
362
- ],
363
- "object": "list"
364
- };
358
+ ],
359
+ "object": "list",
360
+ };
361
366
- final dio = Dio()..interceptors.add(TestInterceptor(mockData));
367
- final service = ServerApiService(dio);
362
+ final dio = Dio()..interceptors.add(TestInterceptor(mockData));
363
+ final service = ServerApiService(dio);
364
369
- final models = await service.fetchModels(testServer);
370
- expect(models, hasLength(1));
371
- expect(models.first.id, "Gemma-4-31B:IQ4_XS");
372
- expect(models.first.architecture, isNull);
373
- });
365
+ final models = await service.fetchModels(testServer);
366
+ expect(models, hasLength(1));
367
+ expect(models.first.id, "Gemma-4-31B:IQ4_XS");
368
+ expect(models.first.architecture, isNull);
369
+ },
370
+ );
371
});
372
373
group('ServerApiService - Ollama load behavior', () {
377
- test('loadModel is a no-op for Ollama (does not POST /api/generate)', () async {
378
- final ollamaServer = Server(
379
- id: 'test-ollama',
380
- name: 'Test Ollama',
381
- type: ServerType.ollama,
382
- host: 'localhost',
383
- port: 11434,
384
- createdAt: DateTime.now(),
385
- lastConnectedAt: DateTime.now(),
386
- );
374
+ test(
375
+ 'loadModel is a no-op for Ollama (does not POST /api/generate)',
376
+ () async {
377
+ final ollamaServer = Server(
378
+ id: 'test-ollama',
379
+ name: 'Test Ollama',
380
+ type: ServerType.ollama,
381
+ host: 'localhost',
382
+ port: 11434,
383
+ createdAt: DateTime.now(),
384
+ lastConnectedAt: DateTime.now(),
385
+ );
386
388
- final dio = Dio()..interceptors.add(RequestRecordingInterceptor());
389
- final service = ServerApiService(dio);
387
+ final dio = Dio()..interceptors.add(RequestRecordingInterceptor());
388
+ final service = ServerApiService(dio);
389
391
- await service.loadModel(ollamaServer, 'llama3.2:latest');
392
- await service.loadModelWithInstanceId(ollamaServer, 'llama3.2:latest');
390
+ await service.loadModel(ollamaServer, 'llama3.2:latest');
391
+ await service.loadModelWithInstanceId(ollamaServer, 'llama3.2:latest');
392
394
- final recorder =
395
- dio.interceptors.firstWhere((i) => i is RequestRecordingInterceptor)
396
- as RequestRecordingInterceptor;
397
- expect(recorder.requests, isEmpty,
398
- reason: 'Ollama should not issue an explicit load request; '
393
+ final recorder =
394
+ dio.interceptors.firstWhere((i) => i is RequestRecordingInterceptor)
395
+ as RequestRecordingInterceptor;
396
+ expect(
397
+ recorder.requests,
398
+ isEmpty,
399
+ reason:
400
+ 'Ollama should not issue an explicit load request; '
401
'calling /api/generate without a prompt can trigger an '
400
- 'auto-pull of the model from the Ollama registry.');
401
- });
402
+ 'auto-pull of the model from the Ollama registry.',
403
+ );
404
+ },
405
+ );
406
403
- test('unloadModel for Ollama posts keep_alive=0 and a valid payload', () async {
404
- final ollamaServer = Server(
405
- id: 'test-ollama',
406
- name: 'Test Ollama',
407
- type: ServerType.ollama,
408
- host: 'localhost',
409
- port: 11434,
410
- createdAt: DateTime.now(),
411
- lastConnectedAt: DateTime.now(),
412
- );
407
+ test(
408
+ 'unloadModel for Ollama posts keep_alive=0 and a valid payload',
409
+ () async {
410
+ final ollamaServer = Server(
411
+ id: 'test-ollama',
412
+ name: 'Test Ollama',
413
+ type: ServerType.ollama,
414
+ host: 'localhost',
415
+ port: 11434,
416
+ createdAt: DateTime.now(),
417
+ lastConnectedAt: DateTime.now(),
418
+ );
419
414
- final dio = Dio()..interceptors.add(RequestRecordingInterceptor());
415
- final service = ServerApiService(dio);
420
+ final dio = Dio()..interceptors.add(RequestRecordingInterceptor());
421
+ final service = ServerApiService(dio);
422
417
- await service.unloadModel(ollamaServer, 'llama3.2:latest');
423
+ await service.unloadModel(ollamaServer, 'llama3.2:latest');
424
419
- final recorder =
420
- dio.interceptors.firstWhere((i) => i is RequestRecordingInterceptor)
421
- as RequestRecordingInterceptor;
422
- expect(recorder.requests, hasLength(1));
423
- expect(recorder.requests.first.path, endsWith('/api/generate'));
424
- expect(recorder.requests.first.data, containsPair('keep_alive', 0));
425
- expect(recorder.requests.first.data, containsPair('model', 'llama3.2:latest'));
426
- expect(recorder.requests.first.data, containsPair('prompt', ''));
427
- });
425
+ final recorder =
426
+ dio.interceptors.firstWhere((i) => i is RequestRecordingInterceptor)
427
+ as RequestRecordingInterceptor;
428
+ expect(recorder.requests, hasLength(1));
429
+ expect(recorder.requests.first.path, endsWith('/api/generate'));
430
+ expect(recorder.requests.first.data, containsPair('keep_alive', 0));
431
+ expect(
432
+ recorder.requests.first.data,
433
+ containsPair('model', 'llama3.2:latest'),
434
+ );
435
+ expect(recorder.requests.first.data, containsPair('prompt', ''));
436
+ },
437
+ );
438
439
test(
440
'fetchModels enriches Ollama models with vision capability from /api/show',
@@ -517,108 +527,114 @@ void main() {
527
);
528
});
529
520
- test('parses capabilities and per-1M pricing for OpenRouter models', () async {
521
- final data = {
522
- 'data': [
523
- {
524
- 'id': 'openai/gpt-4o',
525
- 'name': 'OpenAI: GPT-4o',
526
- 'created': 1715558400,
527
- 'context_length': 128000,
528
- 'architecture': {
529
- 'modality': 'text+image+file->text',
530
- 'input_modalities': ['text', 'image', 'file'],
531
- 'output_modalities': ['text'],
532
- 'tokenizer': 'GPT',
533
- 'instruct_type': null,
534
- },
535
- 'pricing': {'prompt': '0.0000025', 'completion': '0.00001'},
536
- },
537
- {
538
- 'id': 'deepseek/deepseek-reasoner',
539
- 'name': 'DeepSeek: R1',
540
- 'context_length': 163840,
541
- 'architecture': {
542
- 'modality': 'text->text',
543
- 'input_modalities': ['text'],
544
- 'output_modalities': ['text'],
545
- 'tokenizer': 'DeepSeek',
530
+ test(
531
+ 'parses capabilities and per-1M pricing for OpenRouter models',
532
+ () async {
533
+ final data = {
534
+ 'data': [
535
+ {
536
+ 'id': 'openai/gpt-4o',
537
+ 'name': 'OpenAI: GPT-4o',
538
+ 'created': 1715558400,
539
+ 'context_length': 128000,
540
+ 'architecture': {
541
+ 'modality': 'text+image+file->text',
542
+ 'input_modalities': ['text', 'image', 'file'],
543
+ 'output_modalities': ['text'],
544
+ 'tokenizer': 'GPT',
545
+ 'instruct_type': null,
546
+ },
547
+ 'pricing': {'prompt': '0.0000025', 'completion': '0.00001'},
548
},
547
- 'reasoning': {
548
- 'mandatory': false,
549
- 'default_effort': 'high',
550
- 'supported_efforts': ['max', 'high', 'low'],
549
+ {
550
+ 'id': 'deepseek/deepseek-reasoner',
551
+ 'name': 'DeepSeek: R1',
552
+ 'context_length': 163840,
553
+ 'architecture': {
554
+ 'modality': 'text->text',
555
+ 'input_modalities': ['text'],
556
+ 'output_modalities': ['text'],
557
+ 'tokenizer': 'DeepSeek',
558
+ },
559
+ 'reasoning': {
560
+ 'mandatory': false,
561
+ 'default_effort': 'high',
562
+ 'supported_efforts': ['max', 'high', 'low'],
563
+ },
564
+ 'supported_parameters': ['tools', 'reasoning', 'temperature'],
565
},
552
- 'supported_parameters': ['tools', 'reasoning', 'temperature'],
553
- },
554
- {
555
- 'id': 'meta-llama/llama-3.1-8b-instruct:free',
556
- 'name': 'Meta: Llama 3.1 8B (Free)',
557
- 'context_length': 131072,
558
- 'architecture': {
559
- 'modality': 'text->text',
560
- 'input_modalities': ['text'],
561
- 'output_modalities': ['text'],
562
- 'tokenizer': 'Llama3',
566
+ {
567
+ 'id': 'meta-llama/llama-3.1-8b-instruct:free',
568
+ 'name': 'Meta: Llama 3.1 8B (Free)',
569
+ 'context_length': 131072,
570
+ 'architecture': {
571
+ 'modality': 'text->text',
572
+ 'input_modalities': ['text'],
573
+ 'output_modalities': ['text'],
574
+ 'tokenizer': 'Llama3',
575
+ },
576
+ 'pricing': {'prompt': '0', 'completion': '0'},
577
},
564
- 'pricing': {'prompt': '0', 'completion': '0'},
565
- },
566
- ],
567
- };
578
+ ],
579
+ };
580
569
- final service = ServerApiService(
570
- Dio()..interceptors.add(TestInterceptor(data)),
571
- );
572
- final models = await service.fetchModels(openRouterServer);
581
+ final service = ServerApiService(
582
+ Dio()..interceptors.add(TestInterceptor(data)),
583
+ );
584
+ final models = await service.fetchModels(openRouterServer);
585
574
- expect(models, hasLength(3));
575
- final byId = {for (final m in models) m.id: m};
576
-
577
- final gpt4o = byId['openai/gpt-4o']!;
578
- expect(gpt4o.supportsVision, isTrue);
579
- expect(gpt4o.inputPricePerMillion, closeTo(2.5, 0.0001));
580
- expect(gpt4o.outputPricePerMillion, closeTo(10.0, 0.0001));
581
- expect(gpt4o.pricingLabel, '\$2.50/\$10.00');
582
-
583
- final reasoner = byId['deepseek/deepseek-reasoner']!;
584
- expect(reasoner.supportsReasoning, isTrue);
585
- expect(reasoner.supportsToolUse, isTrue);
586
- expect(reasoner.supportsVision, isFalse);
587
- expect(reasoner.reasoningMandatory, isFalse);
588
- expect(reasoner.defaultReasoningEffort, 'high');
589
- expect(reasoner.supportedReasoningEfforts, ['max', 'high', 'low']);
590
-
591
- final free = byId['meta-llama/llama-3.1-8b-instruct:free']!;
592
- expect(free.isPricingFree, isTrue);
593
- expect(free.pricingLabel, 'Free');
594
- });
586
+ expect(models, hasLength(3));
587
+ final byId = {for (final m in models) m.id: m};
588
596
- test('detects vision via modality when input_modalities is absent', () async {
597
- final data = {
598
- 'data': [
599
- {
600
- 'id': 'google/gemini-pro',
601
- 'name': 'Google: Gemini Pro',
602
- 'context_length': 32768,
603
- 'architecture': {
604
- 'modality': 'text+image->text',
605
- 'tokenizer': 'Gemini',
589
+ final gpt4o = byId['openai/gpt-4o']!;
590
+ expect(gpt4o.supportsVision, isTrue);
591
+ expect(gpt4o.inputPricePerMillion, closeTo(2.5, 0.0001));
592
+ expect(gpt4o.outputPricePerMillion, closeTo(10.0, 0.0001));
593
+ expect(gpt4o.pricingLabel, '\$2.50/\$10.00');
594
+
595
+ final reasoner = byId['deepseek/deepseek-reasoner']!;
596
+ expect(reasoner.supportsReasoning, isTrue);
597
+ expect(reasoner.supportsToolUse, isTrue);
598
+ expect(reasoner.supportsVision, isFalse);
599
+ expect(reasoner.reasoningMandatory, isFalse);
600
+ expect(reasoner.defaultReasoningEffort, 'high');
601
+ expect(reasoner.supportedReasoningEfforts, ['max', 'high', 'low']);
602
+
603
+ final free = byId['meta-llama/llama-3.1-8b-instruct:free']!;
604
+ expect(free.isPricingFree, isTrue);
605
+ expect(free.pricingLabel, 'Free');
606
+ },
607
+ );
608
+
609
+ test(
610
+ 'detects vision via modality when input_modalities is absent',
611
+ () async {
612
+ final data = {
613
+ 'data': [
614
+ {
615
+ 'id': 'google/gemini-pro',
616
+ 'name': 'Google: Gemini Pro',
617
+ 'context_length': 32768,
618
+ 'architecture': {
619
+ 'modality': 'text+image->text',
620
+ 'tokenizer': 'Gemini',
621
+ },
622
},
607
- },
608
- ],
609
- };
623
+ ],
624
+ };
625
611
- final service = ServerApiService(
612
- Dio()..interceptors.add(TestInterceptor(data)),
613
- );
614
- final models = await service.fetchModels(openRouterServer);
626
+ final service = ServerApiService(
627
+ Dio()..interceptors.add(TestInterceptor(data)),
628
+ );
629
+ final models = await service.fetchModels(openRouterServer);
630
616
- expect(models, hasLength(1));
617
- expect(models.single.supportsVision, isTrue);
618
- expect(models.single.supportsReasoning, isFalse);
619
- expect(models.single.inputPricePerMillion, isNull);
620
- expect(models.single.pricingLabel, isNull);
621
- });
631
+ expect(models, hasLength(1));
632
+ expect(models.single.supportsVision, isTrue);
633
+ expect(models.single.supportsReasoning, isFalse);
634
+ expect(models.single.inputPricePerMillion, isNull);
635
+ expect(models.single.pricingLabel, isNull);
636
+ },
637
+ );
638
639
test('parses mandatory reasoning with a single supported effort', () async {
640
final data = {
@@ -627,10 +643,7 @@ void main() {
643
'id': 'openai/gpt-5-pro',
644
'name': 'OpenAI: GPT-5 Pro',
645
'context_length': 200000,
630
- 'architecture': {
631
- 'modality': 'text->text',
632
- 'tokenizer': 'GPT',
633
- },
646
+ 'architecture': {'modality': 'text->text', 'tokenizer': 'GPT'},
647
'reasoning': {
648
'mandatory': true,
649
'default_effort': 'high',
@@ -653,16 +666,14 @@ void main() {
666
expect(model.defaultReasoningEffort, 'high');
667
});
668
656
- test('treats non-finite pricing values as unknown', () async { final data = {
669
+ test('treats non-finite pricing values as unknown', () async {
670
+ final data = {
671
'data': [
672
{
673
'id': 'weird/model',
674
'name': 'Weird Model',
675
'context_length': 8192,
662
- 'architecture': {
663
- 'modality': 'text->text',
664
- 'tokenizer': 'X',
665
- },
676
+ 'architecture': {'modality': 'text->text', 'tokenizer': 'X'},
677
'pricing': {'prompt': '1e309', 'completion': 'Infinity'},
678
},
679
],
@@ -695,76 +706,85 @@ void main() {
706
);
707
});
708
698
- test('loadModelWithInstanceId succeeds with LM Studio response having "type": "llm"', () async {
699
- final successData = {
700
- 'type': 'llm',
701
- 'instance_id': 'liquid/lfm2-1.2b',
702
- 'load_config': {
703
- 'context_length': 4096,
704
- },
705
- };
709
+ test(
710
+ 'loadModelWithInstanceId succeeds with LM Studio response having "type": "llm"',
711
+ () async {
712
+ final successData = {
713
+ 'type': 'llm',
714
+ 'instance_id': 'liquid/lfm2-1.2b',
715
+ 'load_config': {'context_length': 4096},
716
+ };
717
707
- final dio = Dio()..interceptors.add(TestInterceptor(successData));
708
- final service = ServerApiService(dio);
718
+ final dio = Dio()..interceptors.add(TestInterceptor(successData));
719
+ final service = ServerApiService(dio);
720
710
- final instanceId = await service.loadModelWithInstanceId(
711
- lmStudioServer,
712
- 'liquid/lfm2-1.2b',
713
- contextLength: 4096,
714
- );
721
+ final instanceId = await service.loadModelWithInstanceId(
722
+ lmStudioServer,
723
+ 'liquid/lfm2-1.2b',
724
+ contextLength: 4096,
725
+ );
726
716
- expect(instanceId, 'liquid/lfm2-1.2b');
717
- });
727
+ expect(instanceId, 'liquid/lfm2-1.2b');
728
+ },
729
+ );
730
719
- test('loadModelWithInstanceId throws when response contains error object', () async {
720
- final errorData = {
721
- 'error': {
722
- 'type': 'invalid_request_error',
723
- 'message': 'Context length exceeds model limit',
724
- },
725
- };
731
+ test(
732
+ 'loadModelWithInstanceId throws when response contains error object',
733
+ () async {
734
+ final errorData = {
735
+ 'error': {
736
+ 'type': 'invalid_request_error',
737
+ 'message': 'Context length exceeds model limit',
738
+ },
739
+ };
740
727
- final dio = Dio()..interceptors.add(TestInterceptor(errorData));
728
- final service = ServerApiService(dio);
741
+ final dio = Dio()..interceptors.add(TestInterceptor(errorData));
742
+ final service = ServerApiService(dio);
743
730
- expect(
731
- () => service.loadModelWithInstanceId(
732
- lmStudioServer,
733
- 'liquid/lfm2-1.2b',
734
- contextLength: 999999,
735
- ),
736
- throwsA(
737
- predicate(
738
- (e) =>
739
- e is Exception &&
740
- e.toString().contains('invalid_request_error: Context length exceeds model limit'),
744
+ expect(
745
+ () => service.loadModelWithInstanceId(
746
+ lmStudioServer,
747
+ 'liquid/lfm2-1.2b',
748
+ contextLength: 999999,
749
),
742
- ),
743
- );
744
- });
745
-
746
- test('loadModelWithInstanceId throws when response contains status error', () async {
747
- final errorData = {
748
- 'status': 'error',
749
- 'message': 'Failed to allocate memory',
750
- };
751
-
752
- final dio = Dio()..interceptors.add(TestInterceptor(errorData));
753
- final service = ServerApiService(dio);
750
+ throwsA(
751
+ predicate(
752
+ (e) =>
753
+ e is Exception &&
754
+ e.toString().contains(
755
+ 'invalid_request_error: Context length exceeds model limit',
756
+ ),
757
+ ),
758
+ ),
759
+ );
760
+ },
761
+ );
762
755
- expect(
756
- () => service.loadModelWithInstanceId(
757
- lmStudioServer,
758
- 'liquid/lfm2-1.2b',
759
- ),
760
- throwsA(
761
- predicate(
762
- (e) =>
763
- e is Exception &&
764
- e.toString().contains('Failed to allocate memory'),
763
+ test(
764
+ 'loadModelWithInstanceId throws when response contains status error',
765
+ () async {
766
+ final errorData = {
767
+ 'status': 'error',
768
+ 'message': 'Failed to allocate memory',
769
+ };
770
+
771
+ final dio = Dio()..interceptors.add(TestInterceptor(errorData));
772
+ final service = ServerApiService(dio);
773
+
774
+ expect(
775
+ () => service.loadModelWithInstanceId(
776
+ lmStudioServer,
777
+ 'liquid/lfm2-1.2b',
778
),
766
- ),
767
- );
768
- });
779
+ throwsA(
780
+ predicate(
781
+ (e) =>
782
+ e is Exception &&
783
+ e.toString().contains('Failed to allocate memory'),
784
+ ),
785
+ ),
786
+ );
787
+ },
788
+ );
789
});
790
}
test/features/servers/server_card_test.dart
+23
-38
@@ -43,13 +43,8 @@ Server _buildServer({
43
44
void main() {
45
group('ServerCard', () {
46
- testWidgets('renders server name, type chip and address',
47
- (tester) async {
48
- await tester.pumpWidget(
49
- _wrapWithApp(
50
- ServerCard(server: _buildServer()),
51
- ),
52
- );
46
+ testWidgets('renders server name, type chip and address', (tester) async {
47
+ await tester.pumpWidget(_wrapWithApp(ServerCard(server: _buildServer())));
48
await tester.pump();
49
50
expect(find.text('llama home'), findsOneWidget);
@@ -59,25 +54,21 @@ void main() {
54
expect(find.text('192.168.0.210:11434'), findsOneWidget);
55
});
56
62
- testWidgets('shows the Offline status pill when disconnected',
63
- (tester) async {
64
- await tester.pumpWidget(
65
- _wrapWithApp(
66
- ServerCard(server: _buildServer()),
67
- ),
68
- );
57
+ testWidgets('shows the Offline status pill when disconnected', (
58
+ tester,
59
+ ) async {
60
+ await tester.pumpWidget(_wrapWithApp(ServerCard(server: _buildServer())));
61
await tester.pump();
62
63
expect(find.text('Offline'), findsOneWidget);
64
});
65
74
- testWidgets('shows the Connected status pill when connected',
75
- (tester) async {
66
+ testWidgets('shows the Connected status pill when connected', (
67
+ tester,
68
+ ) async {
69
await tester.pumpWidget(
70
_wrapWithApp(
78
- ServerCard(
79
- server: _buildServer(status: ConnectionStatus.connected),
80
- ),
71
+ ServerCard(server: _buildServer(status: ConnectionStatus.connected)),
72
),
73
);
74
await tester.pump();
@@ -85,13 +76,12 @@ void main() {
76
expect(find.text('Connected'), findsOneWidget);
77
});
78
88
- testWidgets('shows the Checking status pill while checking',
89
- (tester) async {
79
+ testWidgets('shows the Checking status pill while checking', (
80
+ tester,
81
+ ) async {
82
await tester.pumpWidget(
83
_wrapWithApp(
92
- ServerCard(
93
- server: _buildServer(status: ConnectionStatus.checking),
94
- ),
84
+ ServerCard(server: _buildServer(status: ConnectionStatus.checking)),
85
),
86
);
87
await tester.pump();
@@ -99,12 +89,11 @@ void main() {
89
expect(find.text('Checking'), findsOneWidget);
90
});
91
102
- testWidgets('shows the default star indicator when server is default',
103
- (tester) async {
92
+ testWidgets('shows the default star indicator when server is default', (
93
+ tester,
94
+ ) async {
95
await tester.pumpWidget(
105
- _wrapWithApp(
106
- ServerCard(server: _buildServer(isDefault: true)),
107
- ),
96
+ _wrapWithApp(ServerCard(server: _buildServer(isDefault: true))),
97
);
98
await tester.pump();
99
@@ -115,12 +104,7 @@ void main() {
104
testWidgets('invokes onTap when the card is tapped', (tester) async {
105
var taps = 0;
106
await tester.pumpWidget(
118
- _wrapWithApp(
119
- ServerCard(
120
- server: _buildServer(),
121
- onTap: () => taps++,
122
- ),
123
- ),
107
+ _wrapWithApp(ServerCard(server: _buildServer(), onTap: () => taps++)),
108
);
109
await tester.pump();
110
@@ -130,8 +114,9 @@ void main() {
114
expect(taps, 1);
115
});
116
133
- testWidgets('renders different content for on-device servers',
134
- (tester) async {
117
+ testWidgets('renders different content for on-device servers', (
118
+ tester,
119
+ ) async {
120
await tester.pumpWidget(
121
_wrapWithApp(
122
ServerCard(
@@ -152,4 +137,4 @@ void main() {
137
expect(find.text('Local inference'), findsOneWidget);
138
});
139
});
155
-}
\ No newline at end of file
140
+}
test/features/servers/server_ollama_cloud_test.dart
+1
-3
@@ -51,9 +51,7 @@ void main() {
51
52
test('api key is sent as Bearer authorization header', () {
53
final server = makeCloudServer(apiKey: 'test-key-123');
54
- expect(server.authHeaders, {
55
- 'Authorization': 'Bearer test-key-123',
56
- });
54
+ expect(server.authHeaders, {'Authorization': 'Bearer test-key-123'});
55
});
56
57
test('no Authorization header when api key is missing', () {
test/features/servers/server_setup_ui_test.dart
+4
-7
@@ -22,8 +22,9 @@ Widget _wrapWithApp(Widget child) {
22
}
23
24
void main() {
25
- testWidgets('shows OpenAI Compatible on the onboarding server picker',
26
- (tester) async {
25
+ testWidgets('shows OpenAI Compatible on the onboarding server picker', (
26
+ tester,
27
+ ) async {
28
await tester.pumpWidget(_wrapWithApp(const OnboardingServerTypeScreen()));
29
await tester.pumpAndSettle();
30
@@ -37,11 +38,7 @@ void main() {
38
addTearDown(controller.dispose);
39
40
await tester.pumpWidget(
40
- _wrapWithApp(
41
- Material(
42
- child: HttpsSchemeHint(controller: controller),
43
- ),
44
- ),
41
+ _wrapWithApp(Material(child: HttpsSchemeHint(controller: controller))),
42
);
43
44
expect(find.text('HTTPS requires SSL'), findsNothing);