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