@tduncan / Cortana / commits / a5f48b1

refactor: unify haptic service, extend document attachment support, and improve localization bootstrap

abdulmominsakib committed Aug 9, 2026 at 18:05 UTC a5f48b1a18e09127bf7ebdc94d37a9f019cce027
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: '![Example picture]($url)',
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 +}