@tduncan / Cortana / commits / dcde961

fix: linter warnings

abdulmominsakib committed Aug 24, 2026 at 13:15 UTC dcde96111cb510b00a4f9be76c7e1c88475e7b7e
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);