9 files changed
+188
-66
analysis_options.yaml
+9
@@ -7,6 +7,15 @@
7
8
# The following line activates a set of recommended lints for Flutter apps,
9
# packages, and plugins designed to encourage good coding practices.
10
+analyzer:
11
+ exclude:
12
+ - build/**
13
+ - android/**
14
+ - ios/**
15
+ - web/**
16
+ - windows/**
17
+ - macos/**
18
+ - linux/**
19
include: package:flutter_lints/flutter.yaml
20
21
linter:
android/app/src/main/AndroidManifest.xml
+1
-2
@@ -7,8 +7,6 @@
7
<uses-permission android:name="android.permission.WAKE_LOCK" />
8
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
9
<uses-permission android:name="android.permission.RECORD_AUDIO" />
10
- <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
11
- <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
10
11
<application
12
android:label="LocalMind"
@@ -90,6 +88,7 @@
88
</intent>
89
<intent>
90
<action android:name="android.speech.RecognitionService" />
91
+ <data android:mimeType="text/plain" />
92
</intent>
93
<intent>
94
<action android:name="android.intent.action.SEND" />
lib/core/components/app_button.dart
+1
-1
@@ -41,7 +41,7 @@ class AppButton extends StatelessWidget {
41
child: CircularProgressIndicator(
42
strokeWidth: 2,
43
color: variant == AppButtonVariant.primary
44
- ? Colors.white
44
+ ? (isDark ? AppColors.darkBackground : Colors.white)
45
: accent,
46
),
47
)
lib/core/theme/app_theme.dart
+1
-1
@@ -109,7 +109,7 @@ class AppTheme {
109
elevatedButtonTheme: ElevatedButtonThemeData(
110
style: ElevatedButton.styleFrom(
111
backgroundColor: AppColors.darkAccent,
112
- foregroundColor: Colors.white,
112
+ foregroundColor: AppColors.darkBackground,
113
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
114
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
115
),
lib/features/chat/data/chat_service.dart
+1
-1
@@ -511,7 +511,7 @@ class LMStudioChatService implements ChatService {
511
final imageItems = await _imageItemsFor(m);
512
final textContent = _messageTextWithAttachments(m);
513
if (textContent.isNotEmpty) {
514
- items.add({'type': 'message', 'content': textContent});
514
+ items.add({'type': 'text', 'content': textContent});
515
}
516
items.addAll(imageItems);
517
}
lib/features/chat/views/components/chat_input_bar.dart
+2
-37
@@ -7,7 +7,6 @@ import 'package:haptic_feedback/haptic_feedback.dart';
7
import 'package:hugeicons/hugeicons.dart';
8
import 'package:image_picker/image_picker.dart';
9
import 'package:localmind/features/chat/views/components/token_usage_indicator.dart';
10
-import 'package:permission_handler/permission_handler.dart';
10
11
import '../../../../core/models/enums.dart';
12
import '../../../../core/providers/app_providers.dart';
@@ -204,41 +203,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
203
);
204
}
205
207
- Future<bool> _ensurePhotoPermission() async {
208
- if (!Platform.isAndroid && !Platform.isIOS) return true;
209
-
210
- var status = await Permission.photos.status;
211
-
212
- if (status.isGranted || status.isLimited) return true;
213
-
214
- status = await Permission.photos.request();
215
-
216
- if (status.isGranted || status.isLimited) return true;
217
-
218
- if (Platform.isAndroid) {
219
- final storage = await Permission.storage.request();
220
-
221
- return storage.isGranted;
222
- }
223
-
224
- return false;
225
- }
226
-
206
Future<void> _pickImages() async {
228
- final granted = await _ensurePhotoPermission();
229
-
230
- if (!granted) {
231
- if (!mounted) return;
232
-
233
- ScaffoldMessenger.of(context).showSnackBar(
234
- SnackBar(
235
- content: Text(AppLocalizations.of(context)!.photo_permission_denied),
236
- ),
237
- );
238
-
239
- return;
240
- }
241
-
207
final picker = ImagePicker();
208
209
final images = await picker.pickMultiImage(imageQuality: 85);
@@ -907,9 +872,10 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
872
Padding(
873
padding: const EdgeInsets.only(left: 8, right: 8, top: 4),
874
child: SizedBox(
910
- height: 68,
875
+ height: 50,
876
877
child: ListView.separated(
878
+ shrinkWrap: true,
879
scrollDirection: Axis.horizontal,
880
881
itemCount: _attachedFiles.length,
@@ -1006,7 +972,6 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
972
),
973
),
974
),
1009
- const SizedBox(height: 8),
975
],
976
977
// Text input row. Sits on top of the action row so the action
lib/features/servers/views/add_server_screen.dart
+17
@@ -25,6 +25,7 @@ class AddServerScreen extends ConsumerStatefulWidget {
25
26
class _AddServerScreenState extends ConsumerState<AddServerScreen> {
27
final _formKey = GlobalKey<FormState>();
28
+ final _scrollController = ScrollController();
29
late TextEditingController _nameController;
30
late TextEditingController _hostController;
31
late TextEditingController _portController;
@@ -70,6 +71,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
71
72
@override
73
void dispose() {
74
+ _scrollController.dispose();
75
_nameController.dispose();
76
_hostController.dispose();
77
_portController.dispose();
@@ -100,6 +102,18 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
102
});
103
}
104
105
+ void _scrollToBottom() {
106
+ WidgetsBinding.instance.addPostFrameCallback((_) {
107
+ if (_scrollController.hasClients) {
108
+ _scrollController.animateTo(
109
+ _scrollController.position.maxScrollExtent,
110
+ duration: const Duration(milliseconds: 300),
111
+ curve: Curves.easeOut,
112
+ );
113
+ }
114
+ });
115
+ }
116
+
117
Future<void> _testConnection() async {
118
if (!_formKey.currentState!.validate()) return;
119
@@ -107,6 +121,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
121
_isTesting = true;
122
_testResult = null;
123
});
124
+ _scrollToBottom();
125
126
final apiService = ref.read(serverApiServiceProvider);
127
final testServer = _buildServer();
@@ -131,6 +146,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
146
setState(() {
147
_isTesting = false;
148
});
149
+ _scrollToBottom();
150
}
151
}
152
@@ -322,6 +338,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
338
body: Form(
339
key: _formKey,
340
child: ListView(
341
+ controller: _scrollController,
342
padding: EdgeInsets.fromLTRB(16, 12, 16, 120 + systemBottomInset),
343
children: [
344
_buildHeaderCard(context),
lib/features/voice_mode/views/components/voice_transcript.dart
+155
-23
@@ -223,20 +223,24 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
223
: Colors.black.withValues(alpha: 0.06),
224
),
225
),
226
- child: SingleChildScrollView(
227
- controller: _scrollController,
228
- physics: const BouncingScrollPhysics(),
229
- child: Text(
230
- widget.response,
231
- style: TextStyle(
232
- fontSize: 14,
233
- fontWeight: FontWeight.w400,
234
- color: isDark
235
- ? Colors.white.withValues(alpha: 0.75)
236
- : Colors.black.withValues(alpha: 0.7),
237
- height: 1.45,
226
+ child: _FadedScrollContainer(
227
+ scrollController: _scrollController,
228
+ fadeHeight: 22.0,
229
+ child: SingleChildScrollView(
230
+ controller: _scrollController,
231
+ physics: const BouncingScrollPhysics(),
232
+ child: Text(
233
+ widget.response,
234
+ style: TextStyle(
235
+ fontSize: 14,
236
+ fontWeight: FontWeight.w400,
237
+ color: isDark
238
+ ? Colors.white.withValues(alpha: 0.75)
239
+ : Colors.black.withValues(alpha: 0.7),
240
+ height: 1.45,
241
+ ),
242
+ textAlign: TextAlign.start,
243
),
239
- textAlign: TextAlign.start,
244
),
245
),
246
);
@@ -397,16 +401,20 @@ class _SpeakingTranscriptState extends ConsumerState<_SpeakingTranscript> {
401
402
return ConstrainedBox(
403
constraints: BoxConstraints(maxHeight: maxHeight),
400
- child: SingleChildScrollView(
401
- key: const ValueKey('speaking-scroll'),
402
- controller: _scrollController,
403
- physics: const BouncingScrollPhysics(),
404
- child: SizedBox(
405
- width: constraints.maxWidth,
406
- child: RichText(
407
- key: const ValueKey('speaking'),
408
- textAlign: TextAlign.center,
409
- text: textSpan,
404
+ child: _FadedScrollContainer(
405
+ scrollController: _scrollController,
406
+ fadeHeight: 22.0,
407
+ child: SingleChildScrollView(
408
+ key: const ValueKey('speaking-scroll'),
409
+ controller: _scrollController,
410
+ physics: const BouncingScrollPhysics(),
411
+ child: SizedBox(
412
+ width: constraints.maxWidth,
413
+ child: RichText(
414
+ key: const ValueKey('speaking'),
415
+ textAlign: TextAlign.center,
416
+ text: textSpan,
417
+ ),
418
),
419
),
420
),
@@ -728,3 +736,127 @@ class _PulsingDotState extends State<_PulsingDot>
736
);
737
}
738
}
739
+
740
+/// Applies top and bottom fade gradient masks to a scrollable container
741
+/// when its text content scrolls past the top or bottom edges.
742
+class _FadedScrollContainer extends StatefulWidget {
743
+ final Widget child;
744
+ final ScrollController scrollController;
745
+ final double fadeHeight;
746
+
747
+ const _FadedScrollContainer({
748
+ required this.child,
749
+ required this.scrollController,
750
+ this.fadeHeight = 22.0,
751
+ });
752
+
753
+ @override
754
+ State<_FadedScrollContainer> createState() => _FadedScrollContainerState();
755
+}
756
+
757
+class _FadedScrollContainerState extends State<_FadedScrollContainer> {
758
+ double _topFade = 0.0;
759
+ double _bottomFade = 0.0;
760
+
761
+ @override
762
+ void initState() {
763
+ super.initState();
764
+ widget.scrollController.addListener(_updateFade);
765
+ WidgetsBinding.instance.addPostFrameCallback((_) => _updateFade());
766
+ }
767
+
768
+ @override
769
+ void didUpdateWidget(covariant _FadedScrollContainer oldWidget) {
770
+ super.didUpdateWidget(oldWidget);
771
+ if (widget.scrollController != oldWidget.scrollController) {
772
+ oldWidget.scrollController.removeListener(_updateFade);
773
+ widget.scrollController.addListener(_updateFade);
774
+ }
775
+ WidgetsBinding.instance.addPostFrameCallback((_) => _updateFade());
776
+ }
777
+
778
+ @override
779
+ void dispose() {
780
+ widget.scrollController.removeListener(_updateFade);
781
+ super.dispose();
782
+ }
783
+
784
+ void _updateFade() {
785
+ if (!mounted) return;
786
+ if (!widget.scrollController.hasClients) return;
787
+ final position = widget.scrollController.position;
788
+ if (!position.hasContentDimensions) return;
789
+
790
+ final maxScroll = position.maxScrollExtent;
791
+ if (maxScroll <= 0) {
792
+ if (_topFade != 0.0 || _bottomFade != 0.0) {
793
+ setState(() {
794
+ _topFade = 0.0;
795
+ _bottomFade = 0.0;
796
+ });
797
+ }
798
+ return;
799
+ }
800
+
801
+ final offset = position.pixels;
802
+ final remaining = maxScroll - offset;
803
+
804
+ final newTopFade = (offset / widget.fadeHeight).clamp(0.0, 1.0);
805
+ final newBottomFade = (remaining / widget.fadeHeight).clamp(0.0, 1.0);
806
+
807
+ if ((newTopFade - _topFade).abs() > 0.005 ||
808
+ (newBottomFade - _bottomFade).abs() > 0.005) {
809
+ setState(() {
810
+ _topFade = newTopFade;
811
+ _bottomFade = newBottomFade;
812
+ });
813
+ }
814
+ }
815
+
816
+ @override
817
+ Widget build(BuildContext context) {
818
+ return NotificationListener<ScrollNotification>(
819
+ onNotification: (notification) {
820
+ _updateFade();
821
+ return false;
822
+ },
823
+ child: LayoutBuilder(
824
+ builder: (context, constraints) {
825
+ WidgetsBinding.instance.addPostFrameCallback((_) => _updateFade());
826
+
827
+ if (_topFade == 0.0 && _bottomFade == 0.0) {
828
+ return widget.child;
829
+ }
830
+
831
+ return ShaderMask(
832
+ shaderCallback: (Rect bounds) {
833
+ final h = bounds.height;
834
+ if (h <= 0) {
835
+ return const LinearGradient(
836
+ colors: [Colors.black, Colors.black],
837
+ ).createShader(bounds);
838
+ }
839
+ final tStop = (widget.fadeHeight / h).clamp(0.0, 0.35);
840
+ final bStop = 1.0 - (widget.fadeHeight / h).clamp(0.0, 0.35);
841
+
842
+ return LinearGradient(
843
+ begin: Alignment.topCenter,
844
+ end: Alignment.bottomCenter,
845
+ colors: [
846
+ Colors.black.withValues(alpha: 1.0 - _topFade),
847
+ Colors.black,
848
+ Colors.black,
849
+ Colors.black.withValues(alpha: 1.0 - _bottomFade),
850
+ ],
851
+ stops: [0.0, tStop, bStop, 1.0],
852
+ ).createShader(bounds);
853
+ },
854
+ blendMode: BlendMode.dstIn,
855
+ child: widget.child,
856
+ );
857
+ },
858
+ ),
859
+ );
860
+ }
861
+}
862
+
pubspec.yaml
+1
-1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
# In Windows, build-name is used as the major, minor, and patch parts
18
# of the product and file versions while build-number is used as the build suffix.
19
-version: 1.5.9+64
19
+version: 1.6.1+66
20
21
environment:
22
sdk: ">=3.12.0 <4.0.0"