Matthias Nott
2026-07-08 77911f6418ceb771c5f0b6b2aa33328c7aa5530a
feat: continuous text selection, Cmd+Enter to send, deploy.sh OTA/iPad targets
3 files modified
changed files
lib/widgets/input_bar.dart patch | view | blame | history
lib/widgets/message_bubble.dart patch | view | blame | history
tools/deploy.sh patch | view | blame | history
lib/widgets/input_bar.dart
....@@ -1,4 +1,5 @@
11 import 'package:flutter/material.dart';
2
+import 'package:flutter/services.dart';
23
34 import '../providers/providers.dart';
45 import '../theme/app_theme.dart';
....@@ -97,23 +98,37 @@
9798 const SizedBox(width: 8),
9899 // Text field
99100 Expanded(
100
- child: TextField(
101
- controller: textController,
102
- decoration: InputDecoration(
103
- hintText: 'Type a message...',
104
- filled: true,
105
- fillColor: isDark ? AppColors.darkInputBg : AppColors.lightInputBg,
106
- border: OutlineInputBorder(
107
- borderRadius: BorderRadius.circular(24),
108
- borderSide: BorderSide.none,
101
+ child: CallbackShortcuts(
102
+ // Cmd+Enter (or Ctrl+Enter on other keyboards) sends the message.
103
+ // Plain Enter still inserts a newline (textInputAction: newline).
104
+ bindings: <ShortcutActivator, VoidCallback>{
105
+ const SingleActivator(LogicalKeyboardKey.enter, meta: true):
106
+ onSendText,
107
+ const SingleActivator(LogicalKeyboardKey.enter, control: true):
108
+ onSendText,
109
+ },
110
+ child: TextField(
111
+ controller: textController,
112
+ decoration: InputDecoration(
113
+ hintText: 'Type a message...',
114
+ filled: true,
115
+ fillColor: isDark
116
+ ? AppColors.darkInputBg
117
+ : AppColors.lightInputBg,
118
+ border: OutlineInputBorder(
119
+ borderRadius: BorderRadius.circular(24),
120
+ borderSide: BorderSide.none,
121
+ ),
122
+ contentPadding: const EdgeInsets.symmetric(
123
+ horizontal: 16,
124
+ vertical: 10,
125
+ ),
126
+ isDense: true,
109127 ),
110
- contentPadding:
111
- const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
112
- isDense: true,
128
+ textInputAction: TextInputAction.newline,
129
+ maxLines: 4,
130
+ minLines: 1,
113131 ),
114
- textInputAction: TextInputAction.newline,
115
- maxLines: 4,
116
- minLines: 1,
117132 ),
118133 ),
119134 const SizedBox(width: 8),
lib/widgets/message_bubble.dart
....@@ -63,8 +63,8 @@
6363 color: _isUser
6464 ? (isDark ? AppColors.userBubble : AppColors.lightUserBubble)
6565 : (isDark
66
- ? AppColors.assistantBubble
67
- : AppColors.lightAssistantBubble),
66
+ ? AppColors.assistantBubble
67
+ : AppColors.lightAssistantBubble),
6868 borderRadius: BorderRadius.only(
6969 topLeft: const Radius.circular(16),
7070 topRight: const Radius.circular(16),
....@@ -138,44 +138,60 @@
138138 ? Colors.white.withAlpha(20)
139139 : Colors.black.withAlpha(15);
140140
141
- return MarkdownBody(
142
- data: message.content,
143
- selectable: true,
144
- softLineBreak: true,
145
- styleSheet: MarkdownStyleSheet(
146
- p: TextStyle(fontSize: 15, height: 1.4, color: textColor),
147
- h1: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: textColor),
148
- h2: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor),
149
- h3: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: textColor),
150
- strong: TextStyle(fontWeight: FontWeight.bold, color: textColor),
151
- em: TextStyle(fontStyle: FontStyle.italic, color: textColor),
152
- code: TextStyle(
153
- fontSize: 13,
154
- fontFamily: 'monospace',
155
- color: textColor,
156
- backgroundColor: codeBackground,
157
- ),
158
- codeblockDecoration: BoxDecoration(
159
- color: codeBackground,
160
- borderRadius: BorderRadius.circular(8),
161
- ),
162
- codeblockPadding: const EdgeInsets.all(10),
163
- listBullet: TextStyle(fontSize: 15, color: textColor),
164
- blockquoteDecoration: BoxDecoration(
165
- border: Border(
166
- left: BorderSide(color: AppColors.accent, width: 3),
141
+ // SelectionArea gives one continuous selection across all markdown blocks
142
+ // (paragraphs, lists, code). MarkdownBody's own `selectable: true` makes each
143
+ // block a separate SelectableText, which is why dragging snapped paragraph
144
+ // by paragraph — so it's disabled here in favour of the SelectionArea.
145
+ return SelectionArea(
146
+ child: MarkdownBody(
147
+ data: message.content,
148
+ selectable: false,
149
+ softLineBreak: true,
150
+ styleSheet: MarkdownStyleSheet(
151
+ p: TextStyle(fontSize: 15, height: 1.4, color: textColor),
152
+ h1: TextStyle(
153
+ fontSize: 20,
154
+ fontWeight: FontWeight.bold,
155
+ color: textColor,
167156 ),
157
+ h2: TextStyle(
158
+ fontSize: 18,
159
+ fontWeight: FontWeight.bold,
160
+ color: textColor,
161
+ ),
162
+ h3: TextStyle(
163
+ fontSize: 16,
164
+ fontWeight: FontWeight.bold,
165
+ color: textColor,
166
+ ),
167
+ strong: TextStyle(fontWeight: FontWeight.bold, color: textColor),
168
+ em: TextStyle(fontStyle: FontStyle.italic, color: textColor),
169
+ code: TextStyle(
170
+ fontSize: 13,
171
+ fontFamily: 'monospace',
172
+ color: textColor,
173
+ backgroundColor: codeBackground,
174
+ ),
175
+ codeblockDecoration: BoxDecoration(
176
+ color: codeBackground,
177
+ borderRadius: BorderRadius.circular(8),
178
+ ),
179
+ codeblockPadding: const EdgeInsets.all(10),
180
+ listBullet: TextStyle(fontSize: 15, color: textColor),
181
+ blockquoteDecoration: BoxDecoration(
182
+ border: Border(left: BorderSide(color: AppColors.accent, width: 3)),
183
+ ),
184
+ blockquotePadding: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
168185 ),
169
- blockquotePadding: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
170
- ),
171
- onTapLink: (text, href, title) {
172
- if (href != null) {
173
- final uri = Uri.tryParse(href);
174
- if (uri != null) {
175
- launchUrl(uri, mode: LaunchMode.externalApplication);
186
+ onTapLink: (text, href, title) {
187
+ if (href != null) {
188
+ final uri = Uri.tryParse(href);
189
+ if (uri != null) {
190
+ launchUrl(uri, mode: LaunchMode.externalApplication);
191
+ }
176192 }
177
- }
178
- },
193
+ },
194
+ ),
179195 );
180196 }
181197
....@@ -224,8 +240,8 @@
224240 color: _isUser
225241 ? Colors.white.withAlpha(180)
226242 : (isDark
227
- ? AppColors.darkTextSecondary
228
- : AppColors.lightTextSecondary),
243
+ ? AppColors.darkTextSecondary
244
+ : AppColors.lightTextSecondary),
229245 borderRadius: BorderRadius.circular(2),
230246 ),
231247 );
....@@ -242,8 +258,8 @@
242258 color: _isUser
243259 ? Colors.white70
244260 : (isDark
245
- ? AppColors.darkTextTertiary
246
- : AppColors.lightTextSecondary),
261
+ ? AppColors.darkTextTertiary
262
+ : AppColors.lightTextSecondary),
247263 ),
248264 ),
249265 ],
....@@ -289,9 +305,9 @@
289305 _imageCache.remove(_imageCache.keys.first);
290306 }
291307 final raw = message.imageBase64!;
292
- _imageCache[message.id] = Uint8List.fromList(base64Decode(
293
- raw.contains(',') ? raw.split(',').last : raw,
294
- ));
308
+ _imageCache[message.id] = Uint8List.fromList(
309
+ base64Decode(raw.contains(',') ? raw.split(',').last : raw),
310
+ );
295311 }
296312 final bytes = _imageCache[message.id]!;
297313
....@@ -301,9 +317,7 @@
301317 GestureDetector(
302318 onTap: () {
303319 Navigator.of(context).push(
304
- MaterialPageRoute(
305
- builder: (_) => ImageViewer(imageBytes: bytes),
306
- ),
320
+ MaterialPageRoute(builder: (_) => ImageViewer(imageBytes: bytes)),
307321 );
308322 },
309323 child: ClipRRect(
....@@ -348,7 +362,9 @@
348362 if (isPdf) {
349363 icon = Icons.picture_as_pdf;
350364 iconColor = Colors.red;
351
- } else if (mime.contains('spreadsheet') || mime.contains('excel') || mime == 'text/csv') {
365
+ } else if (mime.contains('spreadsheet') ||
366
+ mime.contains('excel') ||
367
+ mime == 'text/csv') {
352368 icon = Icons.table_chart;
353369 iconColor = Colors.green;
354370 } else if (mime.contains('word') || mime.contains('document')) {
....@@ -370,7 +386,9 @@
370386 width: 260,
371387 padding: const EdgeInsets.all(12),
372388 decoration: BoxDecoration(
373
- color: (_isUser ? Colors.white : Theme.of(context).colorScheme.primary).withAlpha(25),
389
+ color:
390
+ (_isUser ? Colors.white : Theme.of(context).colorScheme.primary)
391
+ .withAlpha(25),
374392 borderRadius: BorderRadius.circular(8),
375393 border: Border.all(
376394 color: (_isUser ? Colors.white : Colors.grey).withAlpha(50),
....@@ -402,7 +420,8 @@
402420 '${mime.split('/').last.toUpperCase()} - ${sizeKB} KB',
403421 style: TextStyle(
404422 fontSize: 11,
405
- color: (_isUser ? Colors.white : Colors.grey).withAlpha(180),
423
+ color: (_isUser ? Colors.white : Colors.grey)
424
+ .withAlpha(180),
406425 ),
407426 ),
408427 ],
....@@ -449,7 +468,8 @@
449468 // Other files: save to temp and share
450469 final ext = _mimeToExt(mime);
451470 final dir = await getTemporaryDirectory();
452
- final fileName = '${message.content.isNotEmpty ? message.content.replaceAll(RegExp(r'[^\w\s.-]'), '').trim() : 'file'}.$ext';
471
+ final fileName =
472
+ '${message.content.isNotEmpty ? message.content.replaceAll(RegExp(r'[^\w\s.-]'), '').trim() : 'file'}.$ext';
453473 final file = File('${dir.path}/$fileName');
454474 await file.writeAsBytes(bytes);
455475
....@@ -458,9 +478,9 @@
458478 );
459479 } catch (e) {
460480 if (context.mounted) {
461
- ScaffoldMessenger.of(context).showSnackBar(
462
- SnackBar(content: Text('Could not open file: $e')),
463
- );
481
+ ScaffoldMessenger.of(
482
+ context,
483
+ ).showSnackBar(SnackBar(content: Text('Could not open file: $e')));
464484 }
465485 }
466486 }
....@@ -469,9 +489,11 @@
469489 const map = {
470490 'application/pdf': 'pdf',
471491 'application/msword': 'doc',
472
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
492
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
493
+ 'docx',
473494 'application/vnd.ms-excel': 'xls',
474
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
495
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
496
+ 'xlsx',
475497 'text/plain': 'txt',
476498 'text/csv': 'csv',
477499 'text/markdown': 'md',
....@@ -499,9 +521,7 @@
499521 fontSize: 11,
500522 color: _isUser
501523 ? Colors.white60
502
- : (isDark
503
- ? AppColors.darkTextTertiary
504
- : Colors.grey.shade500),
524
+ : (isDark ? AppColors.darkTextTertiary : Colors.grey.shade500),
505525 ),
506526 ),
507527 if (message.status == MessageStatus.sending) ...[
....@@ -546,8 +566,14 @@
546566 ),
547567 if (onDelete != null)
548568 ListTile(
549
- leading: const Icon(Icons.delete_outline, color: AppColors.error),
550
- title: const Text('Delete', style: TextStyle(color: AppColors.error)),
569
+ leading: const Icon(
570
+ Icons.delete_outline,
571
+ color: AppColors.error,
572
+ ),
573
+ title: const Text(
574
+ 'Delete',
575
+ style: TextStyle(color: AppColors.error),
576
+ ),
551577 onTap: () {
552578 Navigator.pop(ctx);
553579 onDelete?.call();
tools/deploy.sh
....@@ -3,24 +3,26 @@
33 #
44 # Installs a personal (Pro-unlocked) PAILot build onto one or more iOS devices.
55 #
6
-# ── Remote / "fully remote" targets ────────────────────────────────────────
7
-# Installation uses `xcrun devicectl device install`, which reaches the device
8
-# over Apple's CoreDevice tunnel — USB, local network, OR any routable network
9
-# path where the device is paired (e.g. across Tailscale). There is NO special
10
-# remote logic to add: once a device has been paired, `--device <id>` works
11
-# whether it sits on your desk or across the world, as long as it is awake and
12
-# reachable. That is exactly how the iPad here installs — it is a `localNetwork`
13
-# device, never plugged in. (First-time discovery of a NEW device uses .local /
14
-# mDNS, which does not cross networks, so a device must be paired at least once
15
-# on the local network before it can be driven fully remotely.)
16
-# Check reachability any time with: bash tools/deploy.sh --check
6
+# ── Two transports ─────────────────────────────────────────────────────────
7
+# 1. devicectl (default): `xcrun devicectl device install`. Reaches the device
8
+# over Apple's CoreDevice tunnel, but DISCOVERY is mDNS/Bonjour — so it only
9
+# works when the Mac and device share the local network (USB or same wifi).
10
+# It does NOT traverse Tailscale: a device on a remote network shows
11
+# "unavailable" and both its .ts.net name and tailnet IP are rejected.
12
+#
13
+# 2. --ota (fully remote): build + publish the IPA to the aibroker-ota hub, which
14
+# serves it over Tailscale (HTTPS via Tailscale Serve). Open the returned URL
15
+# in Safari on ANY device on the tailnet and tap Install. This is the path to
16
+# use when you are NOT on the local wifi. Requires `aibroker ota up` once.
17
+# (The device UDID must still be in the provisioning profile.)
1718 #
1819 # ── Usage ──────────────────────────────────────────────────────────────────
19
-# bash tools/deploy.sh # Matthias' iPhone (default)
20
+# bash tools/deploy.sh # Matthias' iPhone (default, local network)
2021 # bash tools/deploy.sh -a # Amelie's iPhone
2122 # bash tools/deploy.sh -i # Matthias' iPad
2223 # bash tools/deploy.sh --all # every known device (continues on failure)
23
-# bash tools/deploy.sh --device ID # explicit CoreDevice identifier (any remote device)
24
+# bash tools/deploy.sh --device ID # explicit CoreDevice id (local network)
25
+# bash tools/deploy.sh --ota # remote install over Tailscale (prints URL)
2426 # bash tools/deploy.sh --build # force a fresh Pro-unlocked build first
2527 # bash tools/deploy.sh --check # list devices devicectl can currently reach
2628 set -euo pipefail
....@@ -29,7 +31,9 @@
2931 APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
3032 cd "$APP_DIR"
3133
32
-IPA="build/ios/ipa/pailot.ipa"
34
+IPA_DIR="build/ios/ipa"
35
+IPA="" # resolved after build (flutter names it PAILot.ipa)
36
+resolve_ipa() { IPA="$(ls -t "$IPA_DIR"/*.ipa 2>/dev/null | head -1 || true)"; }
3337
3438 # Known devices — CoreDevice identifiers from `xcrun devicectl list devices`.
3539 IPHONE_MATTHIAS="8708CADC-3330-50B6-AA0A-1655526A573A"
....@@ -38,6 +42,7 @@
3842
3943 TARGETS=() # entries: "identifier|label"
4044 FORCE_BUILD=false
45
+OTA=false
4146
4247 while [[ $# -gt 0 ]]; do
4348 case "$1" in
....@@ -50,23 +55,20 @@
5055 "$IPHONE_AMELIE|Amelie's iPhone")
5156 shift ;;
5257 --device) TARGETS+=("$2|device $2"); shift 2 ;;
58
+ --ota) OTA=true; shift ;;
5359 --build) FORCE_BUILD=true; shift ;;
5460 --check) echo "=== Devices devicectl can reach ==="; xcrun devicectl list devices; exit 0 ;;
55
- -h|--help) sed -n '2,30p' "$0"; exit 0 ;;
61
+ -h|--help) sed -n '2,33p' "$0"; exit 0 ;;
5662 *) echo "Unknown option: $1" >&2; exit 2 ;;
5763 esac
5864 done
59
-
60
-# Default target: Matthias' iPhone.
61
-if [[ ${#TARGETS[@]} -eq 0 ]]; then
62
- TARGETS+=("$IPHONE_MATTHIAS|Matthias' iPhone")
63
-fi
6465
6566 # ── Build a Pro-unlocked personal IPA when needed ──────────────────────────
6667 # PAILOT_PRO unlocks Pro for personal / sideloaded installs ONLY. App Store
6768 # builds go through tools/build-appstore.sh / tools/release.sh, which do NOT
6869 # pass this define — paying users still see the paywall.
69
-if [[ "$FORCE_BUILD" == true || ! -f "$IPA" ]]; then
70
+resolve_ipa
71
+if [[ "$FORCE_BUILD" == true || -z "$IPA" ]]; then
7072 echo "=== Building Pro-unlocked IPA (PAILOT_PRO=true) ==="
7173 flutter build ipa --release --no-pub --export-method development \
7274 --no-tree-shake-icons --dart-define=PAILOT_PRO=true
....@@ -75,9 +77,42 @@
7577 plutil -replace Name -string "PAILot" "$ARCHIVE" 2>/dev/null || true
7678 plutil -replace SchemeName -string "PAILot" "$ARCHIVE" 2>/dev/null || true
7779 fi
80
+ resolve_ipa
7881 else
7982 echo "=== Using existing IPA: $IPA ==="
8083 echo " (Pro status depends on how it was built — use --build to force a fresh Pro build)"
84
+fi
85
+[[ -n "$IPA" && -f "$IPA" ]] || { echo "ERROR: no IPA found in $IPA_DIR" >&2; exit 1; }
86
+
87
+# ── OTA (remote over Tailscale) ────────────────────────────────────────────
88
+if [[ "$OTA" == true ]]; then
89
+ VERSION="$(grep -E '^version:' pubspec.yaml | sed -E 's/version:[[:space:]]*//; s/\+.*//' | head -1)"
90
+ echo ""
91
+ echo "=== Publishing to aibroker-ota hub (Tailscale) ==="
92
+ resp="$(curl -sf -X POST http://127.0.0.1:8765/api/apps \
93
+ -F slug=pailot -F name=PAILot -F bundleId=com.tekmidian.pailot \
94
+ -F "version=${VERSION:-1.0.0}" -F platform=ios -F "file=@${IPA}" 2>&1)" || {
95
+ echo "ERROR: publish failed — is the hub up? Run: aibroker ota up" >&2; exit 1; }
96
+ # Derive the tailnet host for the HTTPS install URL.
97
+ ts="tailscale"; command -v tailscale >/dev/null 2>&1 || ts="/Applications/Tailscale.app/Contents/MacOS/Tailscale"
98
+ 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)"
99
+ echo ""
100
+ if [[ -n "$host" ]]; then
101
+ echo " Install on any tailnet device — open in Safari:"
102
+ echo " https://${host}/install/pailot/"
103
+ else
104
+ echo " Published. Open the install page in Safari on a tailnet device:"
105
+ echo " https://<your-mac-tailnet-host>/install/pailot/"
106
+ fi
107
+ echo ""
108
+ echo "=== Done (OTA). Tap Install in Safari, then force-quit & reopen PAILot. ==="
109
+ exit 0
110
+fi
111
+
112
+# ── devicectl install (local network) ──────────────────────────────────────
113
+# Default target: Matthias' iPhone.
114
+if [[ ${#TARGETS[@]} -eq 0 ]]; then
115
+ TARGETS+=("$IPHONE_MATTHIAS|Matthias' iPhone")
81116 fi
82117
83118 install_one() {
....@@ -87,8 +122,8 @@
87122 if xcrun devicectl device install app --device "$id" "$IPA"; then
88123 echo " OK: $label"
89124 else
90
- echo " WARNING: install on $label failed — is it awake & reachable?"
91
- echo " Try: bash tools/deploy.sh --check"
125
+ echo " WARNING: install on $label failed — awake & on the same network?"
126
+ echo " Off the local wifi? Use: bash tools/deploy.sh --ota"
92127 return 1
93128 fi
94129 }