Skip to content

Commit 79de2fa

Browse files
v1.5.4+312: in-app Field Link Diagnostics screen for TestFlight debug
After 4 builds (307-311) trying to fix iPad-as-host -> iPhone-as-joiner sync where iPhone shows 0 connected even though it joined the iPad session, kDebugMode prints stay silent in TestFlight (release mode) so every diagnosis has been a guess. This patch adds an in-app Diagnostics screen so the tester can see real-time transport state without plugging into Xcode. Settings -> 'FIELD LINK DIAGNOSTICS' opens a screen showing: - GPS: permission_handler status + system status, stream-running flag, last fix lat/lon + age - Active Session: id (last 8 chars), name, security mode, has-pin - BLE Transport: state, active session id, central-connection count, peripheral-subscriber count, per-central maxUpdateLength values, last broadcast / last receive timestamps + ages, message counts, last error - 'How to read this' inline help that names the three failure signatures we've seen across 307-311 Auto-refreshes every second. Copy-to-clipboard button in app bar so testers can paste a screenshot-equivalent into chat. To enable on demand: BleTransport now exposes diagLastBroadcastAt, diagLastReceivedAt, diagPeripheralCentralMaxUpdateLength as public getters. FieldLinkService exposes bleTransportOrNull for the screen to reach the underlying BLE transport even when wrapped in MultiTransport.
1 parent e05f3ba commit 79de2fa

5 files changed

Lines changed: 300 additions & 1 deletion

File tree

lib/services/field_link/field_link_service.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1178,4 +1178,9 @@ class FieldLinkService {
11781178
if (transport is MultiTransport) return transport.bleTransport;
11791179
return null;
11801180
}
1181+
1182+
/// Public accessor for the underlying [BleTransport] used by the
1183+
/// in-app Diagnostics screen (v1.5.4+312). Returns null if BLE is
1184+
/// not part of the active transport stack.
1185+
BleTransport? get bleTransportOrNull => _resolveBleTransport();
11811186
}

lib/services/field_link/transport/ble_transport.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,26 @@ class BleTransport implements TransportService {
198198
String? _diagLastError;
199199
String? get diagLastError => _diagLastError;
200200

201+
/// Per-central `maximumUpdateValueLength` map for the in-app
202+
/// Diagnostics screen (v1.5.4+312). Read-only snapshot of the
203+
/// internal state so the UI can show whether iOS reported the
204+
/// negotiated MTU per subscribed central.
205+
Map<String, int> get diagPeripheralCentralMaxUpdateLength =>
206+
Map.unmodifiable(_peripheralCentralMaxUpdateLength);
207+
208+
/// Timestamp of the last successful `broadcast()` invocation (any
209+
/// path). Used by the Diagnostics screen to show whether the local
210+
/// heartbeat is firing.
211+
DateTime? _diagLastBroadcastAt;
212+
DateTime? get diagLastBroadcastAt => _diagLastBroadcastAt;
213+
214+
/// Timestamp of the last incoming `_onCharacteristicValueReceived`
215+
/// (peripheral-mode central write OR central-mode notify). Used by
216+
/// the Diagnostics screen to show whether peers are actually reaching
217+
/// us at the BLE layer.
218+
DateTime? _diagLastReceivedAt;
219+
DateTime? get diagLastReceivedAt => _diagLastReceivedAt;
220+
201221
// ---------------------------------------------------------------------------
202222
// MTU
203223
// ---------------------------------------------------------------------------
@@ -640,6 +660,7 @@ class BleTransport implements TransportService {
640660
void _onCharacteristicValueReceived(String deviceId, Uint8List value) {
641661
if (value.isEmpty) return;
642662
_diagMessagesReceived++;
663+
_diagLastReceivedAt = DateTime.now();
643664

644665
// Chunking protocol:
645666
// byte 0: flags (0x00 = complete, 0x01 = first chunk, 0x02 = mid,
@@ -744,6 +765,7 @@ class BleTransport implements TransportService {
744765
@override
745766
Future<void> broadcast(Uint8List data) async {
746767
final errors = <String>[];
768+
_diagLastBroadcastAt = DateTime.now();
747769

748770
// Send to central-mode connections (flutter_blue_plus — we connected to them).
749771
for (final deviceId in _connectedDevices.keys.toList()) {

lib/ui/screens/settings/settings_screen.dart

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import '../../common/widgets/mode_selector.dart';
1414
import '../../common/widgets/section_header.dart';
1515
import '../../common/widgets/tactical_card.dart';
1616
import 'widgets/about_screen.dart';
17+
import 'widgets/diagnostics_screen.dart';
1718
import 'widgets/calibration_section.dart';
1819
import 'widgets/field_link_settings.dart';
1920
import 'widgets/subscription_section.dart';
@@ -283,6 +284,24 @@ class SettingsScreen extends ConsumerWidget {
283284
),
284285
),
285286
),
287+
Divider(
288+
color: colors.border,
289+
height: 1,
290+
indent: 12,
291+
endIndent: 12,
292+
),
293+
_NavRow(
294+
icon: Icons.bug_report_outlined,
295+
label: 'FIELD LINK DIAGNOSTICS',
296+
subtitle:
297+
'Real-time GPS / BLE / MPC state for support',
298+
colors: colors,
299+
onTap: () => Navigator.of(context).push(
300+
MaterialPageRoute<void>(
301+
builder: (_) => const DiagnosticsScreen(),
302+
),
303+
),
304+
),
286305
],
287306
),
288307
),
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import 'dart:async';
2+
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter/services.dart';
5+
import 'package:flutter_riverpod/flutter_riverpod.dart';
6+
import 'package:permission_handler/permission_handler.dart';
7+
8+
import '../../../../core/theme/tactical_text_styles.dart';
9+
import '../../../../providers/field_link_provider.dart';
10+
import '../../../../providers/location_provider.dart';
11+
import '../../../../providers/theme_provider.dart';
12+
13+
/// In-app diagnostics screen used to debug Field Link / GPS issues on
14+
/// release builds (TestFlight). On a release build `kDebugMode` is
15+
/// `false` so all the `if (kDebugMode) print(...)` instrumentation in
16+
/// the BLE / MPC / Location services is silent. This screen surfaces
17+
/// the same internal state via a UI the tester can screenshot and
18+
/// send back.
19+
///
20+
/// Added in v1.5.4+312 to debug the iPad-as-host → iPhone-as-joiner
21+
/// regression that survived through builds 307–311.
22+
class DiagnosticsScreen extends ConsumerStatefulWidget {
23+
const DiagnosticsScreen({super.key});
24+
25+
@override
26+
ConsumerState<DiagnosticsScreen> createState() => _DiagnosticsScreenState();
27+
}
28+
29+
class _DiagnosticsScreenState extends ConsumerState<DiagnosticsScreen> {
30+
Timer? _refresh;
31+
String? _systemLocationStatus;
32+
33+
@override
34+
void initState() {
35+
super.initState();
36+
_refresh = Timer.periodic(
37+
const Duration(seconds: 1),
38+
(_) {
39+
if (mounted) setState(() {});
40+
},
41+
);
42+
_readSystemLocationStatus();
43+
}
44+
45+
@override
46+
void dispose() {
47+
_refresh?.cancel();
48+
super.dispose();
49+
}
50+
51+
Future<void> _readSystemLocationStatus() async {
52+
try {
53+
final s = await Permission.locationWhenInUse.status;
54+
if (mounted) setState(() => _systemLocationStatus = s.toString());
55+
} catch (_) {
56+
if (mounted) setState(() => _systemLocationStatus = 'unknown');
57+
}
58+
}
59+
60+
String _ago(DateTime? t) {
61+
if (t == null) return 'never';
62+
final secs = DateTime.now().difference(t).inSeconds;
63+
if (secs < 1) return 'just now';
64+
if (secs < 60) return '${secs}s ago';
65+
if (secs < 3600) return '${(secs / 60).floor()}m ago';
66+
return '${(secs / 3600).floor()}h ago';
67+
}
68+
69+
String _short(String? s) {
70+
if (s == null) return 'null';
71+
if (s.length <= 8) return s;
72+
return '…${s.substring(s.length - 8)}';
73+
}
74+
75+
@override
76+
Widget build(BuildContext context) {
77+
final colors = ref.watch(currentThemeProvider);
78+
final locationService = ref.watch(locationServiceProvider);
79+
final permission = ref.watch(locationPermissionProvider);
80+
final fieldLink = ref.watch(fieldLinkServiceProvider);
81+
final session = fieldLink.activeSession;
82+
final ble = fieldLink.bleTransportOrNull;
83+
84+
final lines = <_DiagLine>[
85+
// ── GPS ──────────────────────────────────────────────────
86+
_DiagLine.section('GPS'),
87+
_DiagLine.kv('Permission (handler)',
88+
permission.maybeWhen(
89+
data: (s) => s.toString(), orElse: () => 'loading')),
90+
_DiagLine.kv('Permission (system)', _systemLocationStatus ?? 'reading…'),
91+
_DiagLine.kv('Stream running', '${locationService.isStreamRunning}'),
92+
_DiagLine.kv('Last position',
93+
locationService.lastPosition == null
94+
? 'null'
95+
: '${locationService.lastPosition!.lat.toStringAsFixed(5)}, '
96+
'${locationService.lastPosition!.lon.toStringAsFixed(5)}'),
97+
_DiagLine.kv('Last fix at',
98+
_ago(locationService.lastPosition?.timestamp)),
99+
100+
// ── Session ──────────────────────────────────────────────
101+
_DiagLine.section('Active Session'),
102+
_DiagLine.kv('ID', _short(session?.id)),
103+
_DiagLine.kv('Name', session?.name ?? '—'),
104+
_DiagLine.kv('Security', session?.securityMode.toString() ?? '—'),
105+
_DiagLine.kv('PIN set', '${session?.pin != null}'),
106+
107+
// ── BLE Transport ────────────────────────────────────────
108+
_DiagLine.section('BLE Transport'),
109+
_DiagLine.kv('State', ble?.currentState.toString() ?? 'no BLE transport'),
110+
_DiagLine.kv('Active session id',
111+
_short(ble?.activeSessionId)),
112+
_DiagLine.kv('Central conns (we → peer)',
113+
'${ble?.diagCentralConnectionCount ?? 0}'),
114+
_DiagLine.kv('Peripheral subs (peer → us)',
115+
'${ble?.diagPeripheralCentralCount ?? 0}'),
116+
_DiagLine.kv('Per-central maxUpdateLen',
117+
ble?.diagPeripheralCentralMaxUpdateLength.isEmpty ?? true
118+
? 'empty'
119+
: ble!.diagPeripheralCentralMaxUpdateLength.values.join(', ')),
120+
_DiagLine.kv('Last broadcast',
121+
_ago(ble?.diagLastBroadcastAt)),
122+
_DiagLine.kv('Last receive',
123+
_ago(ble?.diagLastReceivedAt)),
124+
_DiagLine.kv('Messages received',
125+
'${ble?.diagMessagesReceived ?? 0}'),
126+
_DiagLine.kv('Messages emitted',
127+
'${ble?.diagMessagesEmitted ?? 0}'),
128+
_DiagLine.kv('Last error', ble?.diagLastError ?? '—'),
129+
130+
// ── How to read this ─────────────────────────────────────
131+
_DiagLine.section('How to read this (v1.5.4+312)'),
132+
_DiagLine.note(
133+
'The two key signals when iPhone shows 0 connected even '
134+
'though it joined an iPad host:\n\n'
135+
'1. iPad — "Last broadcast" should tick under 5s. If it stays '
136+
'"never", the iPad is not running heartbeats — usually because '
137+
'"Last fix at" is "never" (no GPS fix → SyncEngine '
138+
'returns early without broadcasting).\n\n'
139+
'2. iPad — "Peripheral subs" should be ≥1 if iPhone is '
140+
'connected. "Per-central maxUpdateLen" should report iOS '
141+
's negotiated MTU (typically 182). If it says "empty" while '
142+
'iPhone shows connected, the CCCD subscribe never reached '
143+
'the iPad.\n\n'
144+
'3. iPhone — "Last receive" should tick under 5s while iPad '
145+
'is broadcasting. If it stays "never", iPad → iPhone notify '
146+
'isn t reaching the central listener.'),
147+
];
148+
149+
return Scaffold(
150+
backgroundColor: colors.bg,
151+
appBar: AppBar(
152+
backgroundColor: colors.bg,
153+
foregroundColor: colors.text,
154+
title: Text('FIELD LINK DIAGNOSTICS',
155+
style: TacticalTextStyles.label(colors)
156+
.copyWith(letterSpacing: 1.5)),
157+
actions: [
158+
IconButton(
159+
icon: const Icon(Icons.copy),
160+
tooltip: 'Copy to clipboard',
161+
onPressed: () async {
162+
final text = lines.map((l) => l.toLine()).join('\n');
163+
await Clipboard.setData(ClipboardData(text: text));
164+
if (!context.mounted) return;
165+
ScaffoldMessenger.of(context).showSnackBar(
166+
const SnackBar(
167+
content: Text('Diagnostics copied'),
168+
duration: Duration(seconds: 1),
169+
),
170+
);
171+
},
172+
),
173+
],
174+
),
175+
body: ListView.builder(
176+
padding: const EdgeInsets.all(12),
177+
itemCount: lines.length,
178+
itemBuilder: (_, i) {
179+
final l = lines[i];
180+
if (l.section != null) {
181+
return Padding(
182+
padding: const EdgeInsets.only(top: 16, bottom: 4),
183+
child: Text(
184+
l.section!.toUpperCase(),
185+
style: TacticalTextStyles.label(colors).copyWith(
186+
color: colors.accent,
187+
letterSpacing: 1.5,
188+
),
189+
),
190+
);
191+
}
192+
if (l.note != null) {
193+
return Padding(
194+
padding: const EdgeInsets.symmetric(vertical: 8),
195+
child: Text(
196+
l.note!,
197+
style: TacticalTextStyles.body(colors).copyWith(
198+
fontSize: 11,
199+
color: colors.text.withValues(alpha: 0.65),
200+
),
201+
),
202+
);
203+
}
204+
return Padding(
205+
padding: const EdgeInsets.symmetric(vertical: 2),
206+
child: Row(
207+
crossAxisAlignment: CrossAxisAlignment.start,
208+
children: [
209+
SizedBox(
210+
width: 200,
211+
child: Text(
212+
l.key!,
213+
style: TacticalTextStyles.body(colors).copyWith(
214+
fontSize: 12,
215+
color: colors.text.withValues(alpha: 0.7),
216+
),
217+
),
218+
),
219+
Expanded(
220+
child: Text(
221+
l.value!,
222+
style: TacticalTextStyles.body(colors).copyWith(
223+
fontSize: 12,
224+
fontFamily: 'monospace',
225+
),
226+
),
227+
),
228+
],
229+
),
230+
);
231+
},
232+
),
233+
);
234+
}
235+
}
236+
237+
class _DiagLine {
238+
final String? section;
239+
final String? key;
240+
final String? value;
241+
final String? note;
242+
_DiagLine._({this.section, this.key, this.value, this.note});
243+
244+
factory _DiagLine.section(String s) => _DiagLine._(section: s);
245+
factory _DiagLine.kv(String k, String v) => _DiagLine._(key: k, value: v);
246+
factory _DiagLine.note(String n) => _DiagLine._(note: n);
247+
248+
String toLine() {
249+
if (section != null) return '\n## ${section!.toUpperCase()}';
250+
if (note != null) return note!;
251+
return '$key: $value';
252+
}
253+
}

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: red_grid_link
22
description: Offline-first MGRS-native proximity coordination platform
33
publish_to: 'none'
4-
version: 1.5.4+311
4+
version: 1.5.4+312
55

66
environment:
77
sdk: '>=3.2.0 <4.0.0'

0 commit comments

Comments
 (0)