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

---
 tools/deploy.sh                 |   81 +++++++++++----
 lib/widgets/input_bar.dart      |   45 ++++++---
 lib/widgets/message_bubble.dart |  148 +++++++++++++++++------------
 3 files changed, 175 insertions(+), 99 deletions(-)

diff --git a/lib/widgets/input_bar.dart b/lib/widgets/input_bar.dart
index 5ba3fa2..ae191b3 100644
--- a/lib/widgets/input_bar.dart
+++ b/lib/widgets/input_bar.dart
@@ -1,4 +1,5 @@
 import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
 
 import '../providers/providers.dart';
 import '../theme/app_theme.dart';
@@ -97,23 +98,37 @@
         const SizedBox(width: 8),
         // Text field
         Expanded(
-          child: TextField(
-            controller: textController,
-            decoration: InputDecoration(
-              hintText: 'Type a message...',
-              filled: true,
-              fillColor: isDark ? AppColors.darkInputBg : AppColors.lightInputBg,
-              border: OutlineInputBorder(
-                borderRadius: BorderRadius.circular(24),
-                borderSide: BorderSide.none,
+          child: CallbackShortcuts(
+            // Cmd+Enter (or Ctrl+Enter on other keyboards) sends the message.
+            // Plain Enter still inserts a newline (textInputAction: newline).
+            bindings: <ShortcutActivator, VoidCallback>{
+              const SingleActivator(LogicalKeyboardKey.enter, meta: true):
+                  onSendText,
+              const SingleActivator(LogicalKeyboardKey.enter, control: true):
+                  onSendText,
+            },
+            child: TextField(
+              controller: textController,
+              decoration: InputDecoration(
+                hintText: 'Type a message...',
+                filled: true,
+                fillColor: isDark
+                    ? AppColors.darkInputBg
+                    : AppColors.lightInputBg,
+                border: OutlineInputBorder(
+                  borderRadius: BorderRadius.circular(24),
+                  borderSide: BorderSide.none,
+                ),
+                contentPadding: const EdgeInsets.symmetric(
+                  horizontal: 16,
+                  vertical: 10,
+                ),
+                isDense: true,
               ),
-              contentPadding:
-                  const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
-              isDense: true,
+              textInputAction: TextInputAction.newline,
+              maxLines: 4,
+              minLines: 1,
             ),
-            textInputAction: TextInputAction.newline,
-            maxLines: 4,
-            minLines: 1,
           ),
         ),
         const SizedBox(width: 8),
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();
diff --git a/tools/deploy.sh b/tools/deploy.sh
index b8bfc45..c64824d 100755
--- a/tools/deploy.sh
+++ b/tools/deploy.sh
@@ -3,24 +3,26 @@
 #
 # Installs a personal (Pro-unlocked) PAILot build onto one or more iOS devices.
 #
-# ── Remote / "fully remote" targets ────────────────────────────────────────
-# Installation uses `xcrun devicectl device install`, which reaches the device
-# over Apple's CoreDevice tunnel — USB, local network, OR any routable network
-# path where the device is paired (e.g. across Tailscale). There is NO special
-# remote logic to add: once a device has been paired, `--device <id>` works
-# whether it sits on your desk or across the world, as long as it is awake and
-# reachable. That is exactly how the iPad here installs — it is a `localNetwork`
-# device, never plugged in. (First-time discovery of a NEW device uses .local /
-# mDNS, which does not cross networks, so a device must be paired at least once
-# on the local network before it can be driven fully remotely.)
-# Check reachability any time with:  bash tools/deploy.sh --check
+# ── Two transports ─────────────────────────────────────────────────────────
+# 1. devicectl (default): `xcrun devicectl device install`. Reaches the device
+#    over Apple's CoreDevice tunnel, but DISCOVERY is mDNS/Bonjour — so it only
+#    works when the Mac and device share the local network (USB or same wifi).
+#    It does NOT traverse Tailscale: a device on a remote network shows
+#    "unavailable" and both its .ts.net name and tailnet IP are rejected.
+#
+# 2. --ota (fully remote): build + publish the IPA to the aibroker-ota hub, which
+#    serves it over Tailscale (HTTPS via Tailscale Serve). Open the returned URL
+#    in Safari on ANY device on the tailnet and tap Install. This is the path to
+#    use when you are NOT on the local wifi. Requires `aibroker ota up` once.
+#    (The device UDID must still be in the provisioning profile.)
 #
 # ── Usage ──────────────────────────────────────────────────────────────────
-#   bash tools/deploy.sh              # Matthias' iPhone (default)
+#   bash tools/deploy.sh              # Matthias' iPhone (default, local network)
 #   bash tools/deploy.sh -a           # Amelie's iPhone
 #   bash tools/deploy.sh -i           # Matthias' iPad
 #   bash tools/deploy.sh --all        # every known device (continues on failure)
-#   bash tools/deploy.sh --device ID  # explicit CoreDevice identifier (any remote device)
+#   bash tools/deploy.sh --device ID  # explicit CoreDevice id (local network)
+#   bash tools/deploy.sh --ota        # remote install over Tailscale (prints URL)
 #   bash tools/deploy.sh --build      # force a fresh Pro-unlocked build first
 #   bash tools/deploy.sh --check      # list devices devicectl can currently reach
 set -euo pipefail
@@ -29,7 +31,9 @@
 APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
 cd "$APP_DIR"
 
-IPA="build/ios/ipa/pailot.ipa"
+IPA_DIR="build/ios/ipa"
+IPA=""                       # resolved after build (flutter names it PAILot.ipa)
+resolve_ipa() { IPA="$(ls -t "$IPA_DIR"/*.ipa 2>/dev/null | head -1 || true)"; }
 
 # Known devices — CoreDevice identifiers from `xcrun devicectl list devices`.
 IPHONE_MATTHIAS="8708CADC-3330-50B6-AA0A-1655526A573A"
@@ -38,6 +42,7 @@
 
 TARGETS=()          # entries: "identifier|label"
 FORCE_BUILD=false
+OTA=false
 
 while [[ $# -gt 0 ]]; do
   case "$1" in
@@ -50,23 +55,20 @@
                 "$IPHONE_AMELIE|Amelie's iPhone")
       shift ;;
     --device)    TARGETS+=("$2|device $2"); shift 2 ;;
+    --ota)       OTA=true; shift ;;
     --build)     FORCE_BUILD=true; shift ;;
     --check)     echo "=== Devices devicectl can reach ==="; xcrun devicectl list devices; exit 0 ;;
-    -h|--help)   sed -n '2,30p' "$0"; exit 0 ;;
+    -h|--help)   sed -n '2,33p' "$0"; exit 0 ;;
     *) echo "Unknown option: $1" >&2; exit 2 ;;
   esac
 done
-
-# Default target: Matthias' iPhone.
-if [[ ${#TARGETS[@]} -eq 0 ]]; then
-  TARGETS+=("$IPHONE_MATTHIAS|Matthias' iPhone")
-fi
 
 # ── Build a Pro-unlocked personal IPA when needed ──────────────────────────
 # PAILOT_PRO unlocks Pro for personal / sideloaded installs ONLY. App Store
 # builds go through tools/build-appstore.sh / tools/release.sh, which do NOT
 # pass this define — paying users still see the paywall.
-if [[ "$FORCE_BUILD" == true || ! -f "$IPA" ]]; then
+resolve_ipa
+if [[ "$FORCE_BUILD" == true || -z "$IPA" ]]; then
   echo "=== Building Pro-unlocked IPA (PAILOT_PRO=true) ==="
   flutter build ipa --release --no-pub --export-method development \
     --no-tree-shake-icons --dart-define=PAILOT_PRO=true
@@ -75,9 +77,42 @@
     plutil -replace Name -string "PAILot" "$ARCHIVE" 2>/dev/null || true
     plutil -replace SchemeName -string "PAILot" "$ARCHIVE" 2>/dev/null || true
   fi
+  resolve_ipa
 else
   echo "=== Using existing IPA: $IPA ==="
   echo "    (Pro status depends on how it was built — use --build to force a fresh Pro build)"
+fi
+[[ -n "$IPA" && -f "$IPA" ]] || { echo "ERROR: no IPA found in $IPA_DIR" >&2; exit 1; }
+
+# ── OTA (remote over Tailscale) ────────────────────────────────────────────
+if [[ "$OTA" == true ]]; then
+  VERSION="$(grep -E '^version:' pubspec.yaml | sed -E 's/version:[[:space:]]*//; s/\+.*//' | head -1)"
+  echo ""
+  echo "=== Publishing to aibroker-ota hub (Tailscale) ==="
+  resp="$(curl -sf -X POST http://127.0.0.1:8765/api/apps \
+    -F slug=pailot -F name=PAILot -F bundleId=com.tekmidian.pailot \
+    -F "version=${VERSION:-1.0.0}" -F platform=ios -F "file=@${IPA}" 2>&1)" || {
+      echo "ERROR: publish failed — is the hub up? Run: aibroker ota up" >&2; exit 1; }
+  # Derive the tailnet host for the HTTPS install URL.
+  ts="tailscale"; command -v tailscale >/dev/null 2>&1 || ts="/Applications/Tailscale.app/Contents/MacOS/Tailscale"
+  host="$("$ts" status --json 2>/dev/null | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).Self.DNSName.replace(/\.$/,""))}catch{}})' 2>/dev/null || true)"
+  echo ""
+  if [[ -n "$host" ]]; then
+    echo "  Install on any tailnet device — open in Safari:"
+    echo "    https://${host}/install/pailot/"
+  else
+    echo "  Published. Open the install page in Safari on a tailnet device:"
+    echo "    https://<your-mac-tailnet-host>/install/pailot/"
+  fi
+  echo ""
+  echo "=== Done (OTA). Tap Install in Safari, then force-quit & reopen PAILot. ==="
+  exit 0
+fi
+
+# ── devicectl install (local network) ──────────────────────────────────────
+# Default target: Matthias' iPhone.
+if [[ ${#TARGETS[@]} -eq 0 ]]; then
+  TARGETS+=("$IPHONE_MATTHIAS|Matthias' iPhone")
 fi
 
 install_one() {
@@ -87,8 +122,8 @@
   if xcrun devicectl device install app --device "$id" "$IPA"; then
     echo "  OK: $label"
   else
-    echo "  WARNING: install on $label failed — is it awake & reachable?"
-    echo "           Try: bash tools/deploy.sh --check"
+    echo "  WARNING: install on $label failed — awake & on the same network?"
+    echo "           Off the local wifi? Use: bash tools/deploy.sh --ota"
     return 1
   fi
 }

--
Gitblit v1.3.1