From 77911f6418ceb771c5f0b6b2aa33328c7aa5530a Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Wed, 08 Jul 2026 18:28:29 +0200
Subject: [PATCH] feat: continuous text selection, Cmd+Enter to send, deploy.sh OTA/iPad targets

---
 lib/widgets/message_bubble.dart |  148 +++++++++++++++++++++++++++++--------------------
 1 files changed, 87 insertions(+), 61 deletions(-)

diff --git a/lib/widgets/message_bubble.dart b/lib/widgets/message_bubble.dart
index 24aa48c..bbb3c63 100644
--- a/lib/widgets/message_bubble.dart
+++ b/lib/widgets/message_bubble.dart
@@ -63,8 +63,8 @@
             color: _isUser
                 ? (isDark ? AppColors.userBubble : AppColors.lightUserBubble)
                 : (isDark
-                    ? AppColors.assistantBubble
-                    : AppColors.lightAssistantBubble),
+                      ? AppColors.assistantBubble
+                      : AppColors.lightAssistantBubble),
             borderRadius: BorderRadius.only(
               topLeft: const Radius.circular(16),
               topRight: const Radius.circular(16),
@@ -138,44 +138,60 @@
         ? Colors.white.withAlpha(20)
         : Colors.black.withAlpha(15);
 
-    return MarkdownBody(
-      data: message.content,
-      selectable: true,
-      softLineBreak: true,
-      styleSheet: MarkdownStyleSheet(
-        p: TextStyle(fontSize: 15, height: 1.4, color: textColor),
-        h1: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: textColor),
-        h2: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor),
-        h3: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: textColor),
-        strong: TextStyle(fontWeight: FontWeight.bold, color: textColor),
-        em: TextStyle(fontStyle: FontStyle.italic, color: textColor),
-        code: TextStyle(
-          fontSize: 13,
-          fontFamily: 'monospace',
-          color: textColor,
-          backgroundColor: codeBackground,
-        ),
-        codeblockDecoration: BoxDecoration(
-          color: codeBackground,
-          borderRadius: BorderRadius.circular(8),
-        ),
-        codeblockPadding: const EdgeInsets.all(10),
-        listBullet: TextStyle(fontSize: 15, color: textColor),
-        blockquoteDecoration: BoxDecoration(
-          border: Border(
-            left: BorderSide(color: AppColors.accent, width: 3),
+    // SelectionArea gives one continuous selection across all markdown blocks
+    // (paragraphs, lists, code). MarkdownBody's own `selectable: true` makes each
+    // block a separate SelectableText, which is why dragging snapped paragraph
+    // by paragraph — so it's disabled here in favour of the SelectionArea.
+    return SelectionArea(
+      child: MarkdownBody(
+        data: message.content,
+        selectable: false,
+        softLineBreak: true,
+        styleSheet: MarkdownStyleSheet(
+          p: TextStyle(fontSize: 15, height: 1.4, color: textColor),
+          h1: TextStyle(
+            fontSize: 20,
+            fontWeight: FontWeight.bold,
+            color: textColor,
           ),
+          h2: TextStyle(
+            fontSize: 18,
+            fontWeight: FontWeight.bold,
+            color: textColor,
+          ),
+          h3: TextStyle(
+            fontSize: 16,
+            fontWeight: FontWeight.bold,
+            color: textColor,
+          ),
+          strong: TextStyle(fontWeight: FontWeight.bold, color: textColor),
+          em: TextStyle(fontStyle: FontStyle.italic, color: textColor),
+          code: TextStyle(
+            fontSize: 13,
+            fontFamily: 'monospace',
+            color: textColor,
+            backgroundColor: codeBackground,
+          ),
+          codeblockDecoration: BoxDecoration(
+            color: codeBackground,
+            borderRadius: BorderRadius.circular(8),
+          ),
+          codeblockPadding: const EdgeInsets.all(10),
+          listBullet: TextStyle(fontSize: 15, color: textColor),
+          blockquoteDecoration: BoxDecoration(
+            border: Border(left: BorderSide(color: AppColors.accent, width: 3)),
+          ),
+          blockquotePadding: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
         ),
-        blockquotePadding: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
-      ),
-      onTapLink: (text, href, title) {
-        if (href != null) {
-          final uri = Uri.tryParse(href);
-          if (uri != null) {
-            launchUrl(uri, mode: LaunchMode.externalApplication);
+        onTapLink: (text, href, title) {
+          if (href != null) {
+            final uri = Uri.tryParse(href);
+            if (uri != null) {
+              launchUrl(uri, mode: LaunchMode.externalApplication);
+            }
           }
-        }
-      },
+        },
+      ),
     );
   }
 
@@ -224,8 +240,8 @@
                       color: _isUser
                           ? Colors.white.withAlpha(180)
                           : (isDark
-                              ? AppColors.darkTextSecondary
-                              : AppColors.lightTextSecondary),
+                                ? AppColors.darkTextSecondary
+                                : AppColors.lightTextSecondary),
                       borderRadius: BorderRadius.circular(2),
                     ),
                   );
@@ -242,8 +258,8 @@
                   color: _isUser
                       ? Colors.white70
                       : (isDark
-                          ? AppColors.darkTextTertiary
-                          : AppColors.lightTextSecondary),
+                            ? AppColors.darkTextTertiary
+                            : AppColors.lightTextSecondary),
                 ),
               ),
           ],
@@ -289,9 +305,9 @@
         _imageCache.remove(_imageCache.keys.first);
       }
       final raw = message.imageBase64!;
-      _imageCache[message.id] = Uint8List.fromList(base64Decode(
-        raw.contains(',') ? raw.split(',').last : raw,
-      ));
+      _imageCache[message.id] = Uint8List.fromList(
+        base64Decode(raw.contains(',') ? raw.split(',').last : raw),
+      );
     }
     final bytes = _imageCache[message.id]!;
 
@@ -301,9 +317,7 @@
         GestureDetector(
           onTap: () {
             Navigator.of(context).push(
-              MaterialPageRoute(
-                builder: (_) => ImageViewer(imageBytes: bytes),
-              ),
+              MaterialPageRoute(builder: (_) => ImageViewer(imageBytes: bytes)),
             );
           },
           child: ClipRRect(
@@ -348,7 +362,9 @@
     if (isPdf) {
       icon = Icons.picture_as_pdf;
       iconColor = Colors.red;
-    } else if (mime.contains('spreadsheet') || mime.contains('excel') || mime == 'text/csv') {
+    } else if (mime.contains('spreadsheet') ||
+        mime.contains('excel') ||
+        mime == 'text/csv') {
       icon = Icons.table_chart;
       iconColor = Colors.green;
     } else if (mime.contains('word') || mime.contains('document')) {
@@ -370,7 +386,9 @@
         width: 260,
         padding: const EdgeInsets.all(12),
         decoration: BoxDecoration(
-          color: (_isUser ? Colors.white : Theme.of(context).colorScheme.primary).withAlpha(25),
+          color:
+              (_isUser ? Colors.white : Theme.of(context).colorScheme.primary)
+                  .withAlpha(25),
           borderRadius: BorderRadius.circular(8),
           border: Border.all(
             color: (_isUser ? Colors.white : Colors.grey).withAlpha(50),
@@ -402,7 +420,8 @@
                         '${mime.split('/').last.toUpperCase()} - ${sizeKB} KB',
                         style: TextStyle(
                           fontSize: 11,
-                          color: (_isUser ? Colors.white : Colors.grey).withAlpha(180),
+                          color: (_isUser ? Colors.white : Colors.grey)
+                              .withAlpha(180),
                         ),
                       ),
                     ],
@@ -449,7 +468,8 @@
       // Other files: save to temp and share
       final ext = _mimeToExt(mime);
       final dir = await getTemporaryDirectory();
-      final fileName = '${message.content.isNotEmpty ? message.content.replaceAll(RegExp(r'[^\w\s.-]'), '').trim() : 'file'}.$ext';
+      final fileName =
+          '${message.content.isNotEmpty ? message.content.replaceAll(RegExp(r'[^\w\s.-]'), '').trim() : 'file'}.$ext';
       final file = File('${dir.path}/$fileName');
       await file.writeAsBytes(bytes);
 
@@ -458,9 +478,9 @@
       );
     } catch (e) {
       if (context.mounted) {
-        ScaffoldMessenger.of(context).showSnackBar(
-          SnackBar(content: Text('Could not open file: $e')),
-        );
+        ScaffoldMessenger.of(
+          context,
+        ).showSnackBar(SnackBar(content: Text('Could not open file: $e')));
       }
     }
   }
@@ -469,9 +489,11 @@
     const map = {
       'application/pdf': 'pdf',
       'application/msword': 'doc',
-      'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
+      'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
+          'docx',
       'application/vnd.ms-excel': 'xls',
-      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
+      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
+          'xlsx',
       'text/plain': 'txt',
       'text/csv': 'csv',
       'text/markdown': 'md',
@@ -499,9 +521,7 @@
             fontSize: 11,
             color: _isUser
                 ? Colors.white60
-                : (isDark
-                    ? AppColors.darkTextTertiary
-                    : Colors.grey.shade500),
+                : (isDark ? AppColors.darkTextTertiary : Colors.grey.shade500),
           ),
         ),
         if (message.status == MessageStatus.sending) ...[
@@ -546,8 +566,14 @@
             ),
             if (onDelete != null)
               ListTile(
-                leading: const Icon(Icons.delete_outline, color: AppColors.error),
-                title: const Text('Delete', style: TextStyle(color: AppColors.error)),
+                leading: const Icon(
+                  Icons.delete_outline,
+                  color: AppColors.error,
+                ),
+                title: const Text(
+                  'Delete',
+                  style: TextStyle(color: AppColors.error),
+                ),
                 onTap: () {
                   Navigator.pop(ctx);
                   onDelete?.call();

--
Gitblit v1.3.1