@tduncan / Cortana / commits / 7b81033

refactor: remove redundant photo permissions, implement edge-fade scrolling, and update project analyzer exclusions

abdulmominsakib committed Aug 2, 2026 at 18:52 UTC 7b810338ac4538c8ada8d022507b88da5a660543
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"