19 files changed
+340
-61
lib/bootstrap/bootstrap_screen.dart
+10
-3
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
3
import 'package:flutter_localizations/flutter_localizations.dart';
4
import 'package:loading_animation_widget/loading_animation_widget.dart';
5
import '../core/theme/colors.dart';
6
+import '../core/utils/locale_utils.dart';
7
import '../l10n/app_localizations.dart';
8
import 'bootstrap_state.dart';
9
@@ -40,8 +41,14 @@ class BootstrapScreen extends StatelessWidget {
41
final brightness =
42
WidgetsBinding.instance.platformDispatcher.platformBrightness;
43
final isDark = brightness == Brightness.dark;
43
- final locale = this.locale ??
44
- WidgetsBinding.instance.platformDispatcher.locale;
44
+ final requestedLocale =
45
+ this.locale ?? WidgetsBinding.instance.platformDispatcher.locale;
46
+ final locale =
47
+ findSupportedLocale(
48
+ requestedLocale.toLanguageTag(),
49
+ AppLocalizations.supportedLocales,
50
+ ) ??
51
+ const Locale('en');
52
53
return Localizations(
54
locale: locale,
@@ -146,4 +153,4 @@ class BootstrapScreen extends StatelessWidget {
153
}),
154
);
155
}
149
-}
\ No newline at end of file
156
+}
lib/core/services/app_haptics.dart
new
+32
@@ -0,0 +1,32 @@
1
+import 'package:flutter_riverpod/flutter_riverpod.dart';
2
+import 'package:haptic_feedback/haptic_feedback.dart';
3
+
4
+import '../providers/app_providers.dart';
5
+
6
+typedef HapticVibrate = Future<void> Function(HapticsType type);
7
+
8
+/// The single setting-aware entry point for application haptic feedback.
9
+class AppHaptics {
10
+ AppHaptics(this._isEnabled, {HapticVibrate? vibrate})
11
+ : _vibrate = vibrate ?? Haptics.vibrate;
12
+
13
+ final bool Function() _isEnabled;
14
+ final HapticVibrate _vibrate;
15
+
16
+ Future<void> vibrate(HapticsType type) async {
17
+ if (!_isEnabled()) return;
18
+ try {
19
+ await _vibrate(type);
20
+ } catch (_) {
21
+ // Haptics are best-effort on unsupported devices and simulators.
22
+ }
23
+ }
24
+
25
+ Future<void> light() => vibrate(HapticsType.light);
26
+ Future<void> medium() => vibrate(HapticsType.medium);
27
+ Future<void> selection() => vibrate(HapticsType.selection);
28
+}
29
+
30
+final appHapticsProvider = Provider<AppHaptics>((ref) {
31
+ return AppHaptics(() => ref.read(settingsProvider).hapticFeedbackEnabled);
32
+});
lib/features/chat/data/chat_service.dart
+9
-9
@@ -559,15 +559,15 @@ class LMStudioChatService implements ChatService {
559
return items;
560
}
561
562
- /// Builds the text portion of a typed input item, inlining any `.txt`/`.md`
563
- /// attachment contents into the message text so the model actually receives
562
+ /// Builds the text portion of a typed input item, extracting supported
563
+ /// document contents into the message text so the model actually receives
564
/// them. Image attachments are handled separately by [_imageItemsFor].
565
Future<String> _messageTextWithAttachments(Message m) async {
566
var textContent = m.content;
567
final paths = m.attachmentPaths ?? const <String>[];
568
for (final path in paths) {
569
- if (!AttachmentHelpers.isTextPath(path)) continue;
570
- final text = await AttachmentHelpers.readTextFile(path);
569
+ if (!AttachmentHelpers.isDocumentPath(path)) continue;
570
+ final text = await AttachmentHelpers.readDocumentFile(path);
571
if (text == null) continue;
572
final fileName = AttachmentHelpers.fileNameOf(path);
573
if (fileName.isEmpty) continue;
@@ -1069,8 +1069,8 @@ class OllamaChatService implements ChatService {
1069
final images = <String>[];
1070
1071
for (final path in m.attachmentPaths ?? const <String>[]) {
1072
- if (AttachmentHelpers.isTextPath(path)) {
1073
- final text = await AttachmentHelpers.readTextFile(path);
1072
+ if (AttachmentHelpers.isDocumentPath(path)) {
1073
+ final text = await AttachmentHelpers.readDocumentFile(path);
1074
if (text != null) {
1075
textContent = AttachmentHelpers.appendTextAttachment(
1076
textContent,
@@ -1508,7 +1508,7 @@ void _normalizeAssistantPrefill(
1508
}
1509
1510
/// Builds the OpenAI-style request payload for a single [Message], including
1511
-/// attachments. `.txt`/`.md` files are read and inlined into the text content
1511
+/// attachments. Supported documents are read and inlined into the text content
1512
/// (mirroring the Ollama path), and image attachments are emitted as OpenAI
1513
/// multimodal `image_url` content parts carrying base64 data URLs.
1514
///
@@ -1529,8 +1529,8 @@ Future<Map<String, dynamic>> _buildOpenAiApiMessage(
1529
final imageParts = <Map<String, dynamic>>[];
1530
1531
for (final path in m.attachmentPaths ?? const <String>[]) {
1532
- if (AttachmentHelpers.isTextPath(path)) {
1533
- final text = await AttachmentHelpers.readTextFile(path);
1532
+ if (AttachmentHelpers.isDocumentPath(path)) {
1533
+ final text = await AttachmentHelpers.readDocumentFile(path);
1534
if (text != null) {
1535
textContent = AttachmentHelpers.appendTextAttachment(
1536
textContent,
lib/features/chat/providers/chat_notifier.dart
+3
-4
@@ -2,7 +2,6 @@ import 'dart:async';
2
import 'dart:io';
3
import 'dart:math';
4
5
-import 'package:flutter/services.dart';
5
import 'package:flutter_riverpod/flutter_riverpod.dart';
6
import 'package:localmind/core/logger/app_logger.dart';
7
import 'package:localmind/core/models/enums.dart';
@@ -12,6 +11,7 @@ import 'package:localmind/core/providers/service_providers.dart';
11
import 'package:localmind/core/providers/chat_background_service_provider.dart';
12
import 'package:localmind/core/providers/storage_providers.dart';
13
import 'package:localmind/core/services/crash_report_service.dart';
14
+import 'package:localmind/core/services/app_haptics.dart';
15
import 'package:localmind/core/services/message_save_service.dart';
16
import 'package:localmind/core/storage/entities.dart';
17
import 'package:localmind/features/conversations/data/models/conversation.dart';
@@ -989,9 +989,8 @@ class ChatNotifier extends Notifier<ChatState> {
989
.copyWith(isProcessing: true);
990
_latestStreamingMessage = streamingAssistantMessage;
991
stateNeedsUpdate = true;
992
- if (!wasProcessing &&
993
- ref.read(settingsProvider).hapticFeedbackEnabled) {
994
- HapticFeedback.lightImpact();
992
+ if (!wasProcessing) {
993
+ ref.read(appHapticsProvider).light();
994
}
995
break;
996
case ChatResponseType.timeoutError:
lib/features/chat/utils/attachment_helpers.dart
+56
-2
@@ -1,11 +1,26 @@
1
import 'dart:convert';
2
import 'dart:io';
3
4
+import 'package:pdfrx/pdfrx.dart';
5
+
6
class AttachmentHelpers {
7
AttachmentHelpers._();
8
9
static const _imageExtensions = {'jpg', 'jpeg', 'png', 'gif', 'webp'};
8
- static const _textExtensions = {'txt', 'md'};
10
+ static const _textExtensions = {
11
+ 'txt',
12
+ 'md',
13
+ 'csv',
14
+ 'json',
15
+ 'yaml',
16
+ 'yml',
17
+ 'xml',
18
+ 'html',
19
+ 'htm',
20
+ 'log',
21
+ };
22
+
23
+ static const supportedDocumentExtensions = [..._textExtensions, 'pdf'];
24
25
static String extensionOf(String path) {
26
final name = path.split(Platform.pathSeparator).last;
@@ -23,6 +38,11 @@ class AttachmentHelpers {
38
static bool isTextPath(String path) =>
39
_textExtensions.contains(extensionOf(path));
40
41
+ static bool isPdfPath(String path) => extensionOf(path) == 'pdf';
42
+
43
+ static bool isDocumentPath(String path) =>
44
+ isTextPath(path) || isPdfPath(path);
45
+
46
static String mimeTypeForImage(String path) {
47
return switch (extensionOf(path)) {
48
'png' => 'image/png',
@@ -42,6 +62,36 @@ class AttachmentHelpers {
62
}
63
}
64
65
+ static Future<String?> readDocumentFile(String path) {
66
+ if (isPdfPath(path)) return _readPdfText(path);
67
+ if (isTextPath(path)) return readTextFile(path);
68
+ return Future.value(null);
69
+ }
70
+
71
+ static Future<String?> _readPdfText(String path) async {
72
+ PdfDocument? document;
73
+ try {
74
+ final file = File(path);
75
+ if (!await file.exists()) return null;
76
+
77
+ document = await PdfDocument.openFile(path);
78
+ final text = StringBuffer();
79
+ for (final page in document.pages) {
80
+ final pageText = await page.loadText();
81
+ final content = pageText?.fullText.trim() ?? '';
82
+ if (content.isEmpty) continue;
83
+ if (text.isNotEmpty) text.writeln('\n--- Page ${page.pageNumber} ---');
84
+ text.write(content);
85
+ }
86
+ final extracted = text.toString().trim();
87
+ return extracted.isEmpty ? null : extracted;
88
+ } catch (_) {
89
+ return null;
90
+ } finally {
91
+ await document?.dispose();
92
+ }
93
+ }
94
+
95
static Future<String?> readImageBase64(String path) async {
96
try {
97
final file = File(path);
@@ -53,7 +103,11 @@ class AttachmentHelpers {
103
}
104
}
105
56
- static String appendTextAttachment(String content, String fileName, String text) {
106
+ static String appendTextAttachment(
107
+ String content,
108
+ String fileName,
109
+ String text,
110
+ ) {
111
final block = '--- $fileName ---\n$text';
112
if (content.trim().isEmpty) return block;
113
return '$content\n\n$block';
lib/features/chat/views/components/chat_bubble/attachment_list.dart
+3
-3
@@ -33,7 +33,7 @@ class _AttachmentItem extends StatelessWidget {
33
}
34
35
void _viewText(BuildContext context) async {
36
- final text = await AttachmentHelpers.readTextFile(path);
36
+ final text = await AttachmentHelpers.readDocumentFile(path);
37
if (!context.mounted) return;
38
if (text == null) {
39
final l10n = AppLocalizations.of(context)!;
@@ -76,7 +76,7 @@ class _AttachmentItem extends StatelessWidget {
76
);
77
}
78
79
- if (AttachmentHelpers.isTextPath(path)) {
79
+ if (AttachmentHelpers.isDocumentPath(path)) {
80
return GestureDetector(
81
onTap: () => _viewText(context),
82
child: Container(
@@ -263,4 +263,4 @@ class _FilePlaceholder extends StatelessWidget {
263
),
264
);
265
}
266
-}
\ No newline at end of file
266
+}
lib/features/chat/views/components/chat_input_bar.dart
+12
-13
@@ -3,13 +3,13 @@ import 'dart:io';
3
import 'package:file_picker/file_picker.dart';
4
import 'package:flutter/material.dart';
5
import 'package:flutter_riverpod/flutter_riverpod.dart';
6
-import 'package:haptic_feedback/haptic_feedback.dart';
6
import 'package:hugeicons/hugeicons.dart';
7
import 'package:image_picker/image_picker.dart';
8
import 'package:localmind/features/chat/views/components/token_usage_indicator.dart';
9
10
import '../../../../core/models/enums.dart';
11
import '../../../../core/providers/app_providers.dart';
12
+import '../../../../core/services/app_haptics.dart';
13
import '../../../../core/theme/colors.dart';
14
import '../../../../l10n/app_localizations.dart';
15
import '../../../saved_messages/views/components/saved_message_picker_sheet.dart';
@@ -229,11 +229,10 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
229
widget.onAttach?.call(_attachedFiles);
230
}
231
232
- Future<void> _pickTextDocument() async {
232
+ Future<void> _pickDocuments() async {
233
final result = await FilePicker.pickFiles(
234
type: FileType.custom,
235
-
236
- allowedExtensions: const ['txt', 'md'],
235
+ allowedExtensions: AttachmentHelpers.supportedDocumentExtensions,
236
);
237
238
if (result == null || result.files.isEmpty) return;
@@ -260,7 +259,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
259
if (!mounted || result == null) return;
260
switch (result) {
261
case AttachAction.documents:
263
- _pickTextDocument();
262
+ _pickDocuments();
263
case AttachAction.images:
264
_pickImages();
265
case AttachAction.savedMessage:
@@ -273,7 +272,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
272
273
final isListening = ref.read(sttProvider).isListening;
274
276
- Haptics.vibrate(HapticsType.light);
275
+ ref.read(appHapticsProvider).light();
276
277
if (isListening) {
278
await stt.stopListening();
@@ -394,7 +393,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
393
),
394
onPressed: !modelReady
395
? () {
397
- Haptics.vibrate(HapticsType.light);
396
+ ref.read(appHapticsProvider).light();
397
ScaffoldMessenger.of(context).showSnackBar(
398
SnackBar(content: Text(l10n.model_required_toast)),
399
);
@@ -406,7 +405,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
405
);
406
}
407
: () {
409
- Haptics.vibrate(HapticsType.light);
408
+ ref.read(appHapticsProvider).light();
409
VoiceModeOverlay.show(context);
410
},
411
tooltip: modelReady ? 'Voice mode' : l10n.model_required_toast,
@@ -446,7 +445,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
445
446
if (!_ensureModelSelected()) return;
447
449
- Haptics.vibrate(HapticsType.medium);
448
+ ref.read(appHapticsProvider).medium();
449
450
if (_sendAsAssistant) {
451
ref
@@ -474,7 +473,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
473
474
if (!_ensureModelSelected()) return;
475
477
- Haptics.vibrate(HapticsType.medium);
476
+ ref.read(appHapticsProvider).medium();
477
ref
478
.read(chatProvider.notifier)
479
.insertMessageWithoutGenerating(
@@ -509,7 +508,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
508
}
509
510
void _handleStop() {
512
- Haptics.vibrate(HapticsType.light);
511
+ ref.read(appHapticsProvider).light();
512
513
widget.onStop();
514
}
@@ -537,7 +536,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
536
: l10n.send_as_user_tooltip,
537
onPressed: widget.enabled
538
? () {
540
- Haptics.vibrate(HapticsType.light);
539
+ ref.read(appHapticsProvider).light();
540
setState(() => _sendAsAssistant = !_sendAsAssistant);
541
}
542
: null,
@@ -1120,7 +1119,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
1119
isCompactFont:
1120
selectedModel?.supportsReasoning == true,
1121
onTap: () {
1123
- Haptics.vibrate(HapticsType.light);
1122
+ ref.read(appHapticsProvider).light();
1123
showModalBottomSheet(
1124
context: context,
1125
isScrollControlled: true,
lib/features/chat/views/components/token_usage_indicator.dart
+5
-5
@@ -1,6 +1,5 @@
1
import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
-import 'package:localmind/core/providers/app_providers.dart';
3
import 'package:localmind/core/theme/colors.dart';
4
import 'package:localmind/features/chat/providers/chat_providers.dart';
5
import 'package:localmind/l10n/app_localizations.dart';
@@ -14,11 +13,12 @@ class TokenUsageIndicator extends ConsumerWidget {
13
Widget build(BuildContext context, WidgetRef ref) {
14
final theme = Theme.of(context);
15
final isDark = theme.brightness == Brightness.dark;
17
- final liveContextLength = ref.watch(activeModelContextLengthProvider).value;
18
- final fallbackContextLength = ref.watch(
19
- settingsProvider.select((s) => s.contextLength),
16
+ // LM Studio's native chat API accepts context_length per request. Use the
17
+ // configured per-chat value here so the indicator reflects the request the
18
+ // app will actually send instead of a stale load-time model value.
19
+ final contextLength = ref.watch(
20
+ chatParamsProvider.select((params) => params.contextLength),
21
);
21
- final int contextLength = liveContextLength ?? fallbackContextLength;
22
23
// totalTokenCount only updates once a response finishes (it's the real
24
// server-reported count), so while one is streaming in, grow the ring
lib/features/settings/views/settings_screen.dart
+7
-3
@@ -11,6 +11,7 @@ import 'package:localmind/l10n/app_localizations.dart';
11
import '../../../core/models/enums.dart';
12
import '../../../core/providers/app_providers.dart';
13
import '../../../core/routes/app_routes.dart';
14
+import '../../../core/services/app_haptics.dart';
15
import '../../../core/services/crash_report_service.dart';
16
import '../../../core/theme/app_theme.dart';
17
import '../../../core/utils/system_insets.dart';
@@ -255,9 +256,12 @@ class SettingsViews extends ConsumerWidget {
256
_ToggleSetting(
257
label: l10n.haptic_feedback,
258
value: settings.hapticFeedbackEnabled,
258
- onChanged: (value) => ref
259
- .read(settingsProvider.notifier)
260
- .setHapticFeedback(value),
259
+ onChanged: (value) {
260
+ ref
261
+ .read(settingsProvider.notifier)
262
+ .setHapticFeedback(value);
263
+ if (value) ref.read(appHapticsProvider).medium();
264
+ },
265
),
266
_ToggleSetting(
267
label: l10n.enable_mcp,
lib/features/voice_mode/views/voice_mode_overlay.dart
+2
-2
@@ -4,11 +4,11 @@ import 'package:flutter/material.dart';
4
import 'package:flutter/services.dart';
5
import 'package:flutter_riverpod/flutter_riverpod.dart';
6
import 'package:go_router/go_router.dart';
7
-import 'package:haptic_feedback/haptic_feedback.dart';
7
import 'package:hugeicons/hugeicons.dart';
8
9
import '../../../../core/models/enums.dart';
10
import '../../../../core/providers/app_providers.dart';
11
+import '../../../../core/services/app_haptics.dart';
12
import '../../chat/providers/chat_notifier.dart';
13
import '../../tts/providers/tts_providers.dart';
14
import '../../tts/views/tts_model_manager_screen.dart';
@@ -106,7 +106,7 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
106
107
return InkWell(
108
onTap: () {
109
- Haptics.vibrate(HapticsType.light);
109
+ ref.read(appHapticsProvider).light();
110
showModalBottomSheet(
111
context: context,
112
isScrollControlled: true,
lib/main.dart
+2
@@ -3,6 +3,7 @@ import 'dart:async';
3
import 'package:flutter/foundation.dart';
4
import 'package:flutter/material.dart';
5
import 'package:just_audio_background/just_audio_background.dart';
6
+import 'package:pdfrx/pdfrx.dart';
7
8
import 'bootstrap/bootstrap_host.dart';
9
import 'core/services/crash_report_service.dart';
@@ -12,6 +13,7 @@ Future<void> main() async {
13
await runZonedGuarded<Future<void>>(
14
() async {
15
WidgetsFlutterBinding.ensureInitialized();
16
+ await pdfrxFlutterInitialize();
17
final crashReports = CrashReportService.instance;
18
await crashReports.initialize();
19
lib/services/voice_feedback_service.dart
+15
-16
@@ -1,9 +1,10 @@
1
import 'dart:async';
2
3
import 'package:audioplayers/audioplayers.dart';
4
-import 'package:flutter/services.dart';
4
import 'package:flutter_riverpod/flutter_riverpod.dart';
5
6
+import '../core/services/app_haptics.dart';
7
+
8
/// Sound + haptic feedback for the voice-to-voice overlay lifecycle.
9
///
10
/// Holds four preloaded [AudioPlayer]s (one per cue) so playback is
@@ -13,6 +14,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
14
/// All playback errors are swallowed — feedback is best-effort and must
15
/// never break the voice pipeline.
16
class VoiceFeedbackService {
17
+ VoiceFeedbackService(this._haptics);
18
+
19
+ final AppHaptics _haptics;
20
final AudioPlayer _connectedPlayer = AudioPlayer();
21
final AudioPlayer _disconnectedPlayer = AudioPlayer();
22
final AudioPlayer _listeningPlayer = AudioPlayer();
@@ -54,36 +58,31 @@ class VoiceFeedbackService {
58
59
/// Session opened, mic is live. Confident, affirming.
60
Future<void> playConnected() async {
61
+ if (_disposed) return;
62
await _play(_connectedPlayer);
58
- await _safeHaptic(HapticFeedback.mediumImpact);
63
+ await _haptics.medium();
64
}
65
66
/// Session closed gracefully. Soft, not alarming.
67
Future<void> playDisconnected() async {
68
+ if (_disposed) return;
69
await _play(_disconnectedPlayer);
64
- await _safeHaptic(HapticFeedback.lightImpact);
70
+ await _haptics.light();
71
}
72
73
/// Mic activated, awaiting user speech. Crisp micro-click.
74
Future<void> playListening() async {
75
+ if (_disposed) return;
76
await _play(_listeningPlayer);
70
- await _safeHaptic(HapticFeedback.selectionClick);
77
+ await _haptics.selection();
78
}
79
80
/// LLM is streaming. Single tap on first chunk only — repeated
81
/// haptics during streaming feel glitchy.
82
Future<void> playGenerating() async {
76
- await _play(_generatingPlayer);
77
- await _safeHaptic(HapticFeedback.lightImpact);
78
- }
79
-
80
- Future<void> _safeHaptic(Future<void> Function() haptic) async {
83
if (_disposed) return;
82
- try {
83
- await haptic();
84
- } catch (_) {
85
- // Swallow — haptics unsupported on some devices / emulators.
86
- }
84
+ await _play(_generatingPlayer);
85
+ await _haptics.light();
86
}
87
88
/// Release every player. Called by [voiceFeedbackProvider]'s
@@ -104,10 +103,10 @@ class VoiceFeedbackService {
103
/// warm for the whole app lifetime so the first phase transition has no
104
/// asset-load delay.
105
final voiceFeedbackProvider = Provider<VoiceFeedbackService>((ref) {
107
- final service = VoiceFeedbackService();
106
+ final service = VoiceFeedbackService(ref.read(appHapticsProvider));
107
// Fire-and-forget: callers don't block on preload.
108
// ignore: discarded_futures
109
service.preload();
110
ref.onDispose(service.dispose);
111
return service;
113
-});
\ No newline at end of file
112
+});
pubspec.lock
+40
@@ -845,6 +845,14 @@ packages:
845
url: "https://pub.dev"
846
source: hosted
847
version: "1.1.7"
848
+ image:
849
+ dependency: transitive
850
+ description:
851
+ name: image
852
+ sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
853
+ url: "https://pub.dev"
854
+ source: hosted
855
+ version: "4.8.0"
856
image_picker:
857
dependency: "direct main"
858
description:
@@ -1285,6 +1293,38 @@ packages:
1293
url: "https://pub.dev"
1294
source: hosted
1295
version: "2.3.0"
1296
+ pdfium_dart:
1297
+ dependency: transitive
1298
+ description:
1299
+ name: pdfium_dart
1300
+ sha256: "86e95c66b09f3245b95c4924f2edce8d4f7c9786876e5c5ee8e36b104a94bbb0"
1301
+ url: "https://pub.dev"
1302
+ source: hosted
1303
+ version: "0.2.5"
1304
+ pdfium_flutter:
1305
+ dependency: transitive
1306
+ description:
1307
+ name: pdfium_flutter
1308
+ sha256: "0b115c0917aef9cf6bb9c4d47d0efc61d97f44844cd76287c921f997c26cf15d"
1309
+ url: "https://pub.dev"
1310
+ source: hosted
1311
+ version: "0.2.3"
1312
+ pdfrx:
1313
+ dependency: "direct main"
1314
+ description:
1315
+ name: pdfrx
1316
+ sha256: acce61944c31bd36f0c3031b1bbf41f59d0b0d4e5e27614fbdc74ff093d55464
1317
+ url: "https://pub.dev"
1318
+ source: hosted
1319
+ version: "2.4.7"
1320
+ pdfrx_engine:
1321
+ dependency: transitive
1322
+ description:
1323
+ name: pdfrx_engine
1324
+ sha256: ef8f8cfa64255bfc91a559dd2d32d7cfe7a0f8003bac297bbf9a0aed1182fae5
1325
+ url: "https://pub.dev"
1326
+ source: hosted
1327
+ version: "0.4.6"
1328
permission_handler:
1329
dependency: "direct main"
1330
description:
pubspec.yaml
+2
-1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
# In Windows, build-name is used as the major, minor, and patch parts
18
# of the product and file versions while build-number is used as the build suffix.
19
-version: 1.6.7+72
19
+version: 1.6.8+73
20
21
environment:
22
sdk: ">=3.12.0 <4.0.0"
@@ -84,6 +84,7 @@ dependencies:
84
speech_to_text: ^7.4.0
85
share_plus: ^13.2.0
86
image_picker: ^1.1.2
87
+ pdfrx: ^2.4.7
88
permission_handler: ^11.4.0
89
auto_size_text_plus: ^3.0.2
90
test/bootstrap/bootstrap_screen_test.dart
new
+20
@@ -0,0 +1,20 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_test/flutter_test.dart';
3
+import 'package:localmind/bootstrap/bootstrap_screen.dart';
4
+import 'package:localmind/bootstrap/bootstrap_state.dart';
5
+
6
+void main() {
7
+ testWidgets('falls back to English for an unsupported device locale', (
8
+ tester,
9
+ ) async {
10
+ await tester.pumpWidget(
11
+ const BootstrapScreen(
12
+ state: BootstrapState(),
13
+ locale: Locale('de', 'DE'),
14
+ ),
15
+ );
16
+
17
+ expect(tester.takeException(), isNull);
18
+ expect(find.text('Initializing...'), findsOneWidget);
19
+ });
20
+}
test/core/services/app_haptics_test.dart
new
+30
@@ -0,0 +1,30 @@
1
+import 'package:flutter_test/flutter_test.dart';
2
+import 'package:haptic_feedback/haptic_feedback.dart';
3
+import 'package:localmind/core/services/app_haptics.dart';
4
+
5
+void main() {
6
+ test('suppresses vibration while haptic feedback is disabled', () async {
7
+ var enabled = false;
8
+ final calls = <HapticsType>[];
9
+ final haptics = AppHaptics(
10
+ () => enabled,
11
+ vibrate: (type) async => calls.add(type),
12
+ );
13
+
14
+ await haptics.light();
15
+ expect(calls, isEmpty);
16
+
17
+ enabled = true;
18
+ await haptics.medium();
19
+ expect(calls, [HapticsType.medium]);
20
+ });
21
+
22
+ test('swallows platform vibration failures', () async {
23
+ final haptics = AppHaptics(
24
+ () => true,
25
+ vibrate: (_) => Future<void>.error(StateError('unsupported')),
26
+ );
27
+
28
+ await expectLater(haptics.selection(), completes);
29
+ });
30
+}
test/features/chat/attachment_helpers_test.dart
new
+19
@@ -0,0 +1,19 @@
1
+import 'package:flutter_test/flutter_test.dart';
2
+import 'package:localmind/features/chat/utils/attachment_helpers.dart';
3
+
4
+void main() {
5
+ test('recognizes supported text documents and PDFs', () {
6
+ expect(AttachmentHelpers.isDocumentPath('/tmp/notes.md'), isTrue);
7
+ expect(AttachmentHelpers.isDocumentPath('/tmp/data.json'), isTrue);
8
+ expect(AttachmentHelpers.isDocumentPath('/tmp/table.csv'), isTrue);
9
+ expect(AttachmentHelpers.isDocumentPath('/tmp/report.PDF'), isTrue);
10
+ expect(AttachmentHelpers.isDocumentPath('/tmp/archive.zip'), isFalse);
11
+ });
12
+
13
+ test('document picker extensions include PDF and common text formats', () {
14
+ expect(
15
+ AttachmentHelpers.supportedDocumentExtensions,
16
+ containsAll(['txt', 'md', 'pdf', 'json', 'csv', 'yaml']),
17
+ );
18
+ });
19
+}
test/features/chat/themed_gpt_markdown_test.dart
new
+27
@@ -0,0 +1,27 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_test/flutter_test.dart';
3
+import 'package:localmind/features/chat/views/components/chat_bubble/markdown/themed_gpt_markdown.dart';
4
+
5
+void main() {
6
+ testWidgets('renders Markdown image URLs with NetworkImage', (tester) async {
7
+ const url = 'https://example.com/picture.png';
8
+
9
+ await tester.pumpWidget(
10
+ const MaterialApp(
11
+ home: Scaffold(
12
+ body: ThemedGptMarkdown(
13
+ content: '',
14
+ isDark: false,
15
+ style: TextStyle(),
16
+ ),
17
+ ),
18
+ ),
19
+ );
20
+
21
+ final networkImages = tester
22
+ .widgetList<Image>(find.byType(Image))
23
+ .map((image) => image.image)
24
+ .whereType<NetworkImage>();
25
+ expect(networkImages.map((image) => image.url), contains(url));
26
+ });
27
+}
test/features/chat/token_usage_indicator_test.dart
new
+46
@@ -0,0 +1,46 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:flutter_test/flutter_test.dart';
4
+import 'package:localmind/features/chat/data/models/chat_parameters.dart';
5
+import 'package:localmind/features/chat/providers/chat_providers.dart';
6
+import 'package:localmind/features/chat/views/components/token_usage_indicator.dart';
7
+import 'package:localmind/l10n/app_localizations.dart';
8
+
9
+class _TestChatNotifier extends ChatNotifier {
10
+ @override
11
+ ChatState build() => const ChatState();
12
+}
13
+
14
+void main() {
15
+ testWidgets('uses the configured per-chat context length', (tester) async {
16
+ await tester.pumpWidget(
17
+ ProviderScope(
18
+ overrides: [
19
+ chatParamsProvider.overrideWithValue(
20
+ const ChatParameters(
21
+ temperature: 0.7,
22
+ topP: 0.9,
23
+ maxTokens: 2048,
24
+ contextLength: 16384,
25
+ ),
26
+ ),
27
+ chatProvider.overrideWith(_TestChatNotifier.new),
28
+ ],
29
+ child: MaterialApp(
30
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
31
+ supportedLocales: AppLocalizations.supportedLocales,
32
+ home: const Scaffold(
33
+ body: TokenUsageIndicator(totalTokenCount: 8192),
34
+ ),
35
+ ),
36
+ ),
37
+ );
38
+
39
+ expect(find.text('50%'), findsOneWidget);
40
+ await tester.tap(find.byType(TokenUsageIndicator));
41
+ await tester.pumpAndSettle();
42
+
43
+ expect(find.text('16384'), findsOneWidget);
44
+ expect(find.text('50.0%'), findsOneWidget);
45
+ });
46
+}