Skip to content

Commit 89869fd

Browse files
fix: permission request flow, tool titles, and mode label truncation
P0 Fixes: - Permission request now handles iOS vs Android BLE correctly (iOS uses Permission.bluetooth, Android uses granular scan/connect/advertise) - Added loading spinner during permission request - Added WidgetsBindingObserver to re-check permissions when returning from Settings - Added debug logging for permission status transitions UI Fixes: - Removed FittedBox from tool card labels (was scaling text too small) - Removed FittedBox from mode selector chips (was truncating "Backcountry") - Mode labels now use 9pt font with visible overflow instead of scaling Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 34b460c commit 89869fd

3 files changed

Lines changed: 162 additions & 59 deletions

File tree

lib/ui/common/widgets/mode_selector.dart

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -107,18 +107,17 @@ class _ModeChip extends StatelessWidget {
107107
const SizedBox(width: 2),
108108
],
109109
Flexible(
110-
child: FittedBox(
111-
fit: BoxFit.scaleDown,
112-
child: Text(
113-
mode.label,
114-
style: TacticalTextStyles.caption(colors).copyWith(
115-
color: isSelected ? Colors.white : colors.text2,
116-
fontWeight:
117-
isSelected ? FontWeight.bold : FontWeight.normal,
118-
fontSize: 11,
119-
),
120-
maxLines: 1,
110+
child: Text(
111+
mode.label,
112+
style: TacticalTextStyles.caption(colors).copyWith(
113+
color: isSelected ? Colors.white : colors.text2,
114+
fontWeight:
115+
isSelected ? FontWeight.bold : FontWeight.normal,
116+
fontSize: 9,
117+
letterSpacing: 0.5,
121118
),
119+
maxLines: 1,
120+
overflow: TextOverflow.visible,
122121
),
123122
),
124123
],

lib/ui/screens/onboarding/widgets/permissions_page.dart

Lines changed: 144 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:io' show Platform;
2+
13
import 'package:flutter/material.dart';
24
import 'package:permission_handler/permission_handler.dart';
35

@@ -23,7 +25,8 @@ class PermissionsPage extends StatefulWidget {
2325
State<PermissionsPage> createState() => _PermissionsPageState();
2426
}
2527

26-
class _PermissionsPageState extends State<PermissionsPage> {
28+
class _PermissionsPageState extends State<PermissionsPage>
29+
with WidgetsBindingObserver {
2730
bool _locationGranted = false;
2831
bool _bluetoothGranted = false;
2932
bool _locationChecked = false;
@@ -34,24 +37,47 @@ class _PermissionsPageState extends State<PermissionsPage> {
3437
@override
3538
void initState() {
3639
super.initState();
40+
WidgetsBinding.instance.addObserver(this);
3741
_checkPermissions();
3842
}
3943

44+
@override
45+
void dispose() {
46+
WidgetsBinding.instance.removeObserver(this);
47+
super.dispose();
48+
}
49+
50+
@override
51+
void didChangeAppLifecycleState(AppLifecycleState state) {
52+
// Re-check permissions when user returns from Settings
53+
if (state == AppLifecycleState.resumed) {
54+
_checkPermissions();
55+
}
56+
}
57+
4058
Future<void> _checkPermissions() async {
4159
try {
4260
final locationStatus = await Permission.locationWhenInUse.status;
43-
final btScanStatus = await Permission.bluetoothScan.status;
61+
62+
// iOS uses Permission.bluetooth; Android uses granular BT permissions
63+
PermissionStatus btStatus;
64+
if (Platform.isIOS) {
65+
btStatus = await Permission.bluetooth.status;
66+
} else {
67+
btStatus = await Permission.bluetoothScan.status;
68+
}
4469

4570
if (mounted) {
4671
setState(() {
4772
_locationGranted = locationStatus.isGranted;
48-
_bluetoothGranted = btScanStatus.isGranted;
73+
_bluetoothGranted = btStatus.isGranted;
4974
_locationChecked = true;
5075
_bluetoothChecked = true;
5176
});
5277
}
53-
} catch (_) {
54-
// Permission handler may not be available on all platforms.
78+
} catch (e) {
79+
debugPrint('Permission check error: $e');
80+
// Ensure buttons are shown even if check fails
5581
if (mounted) {
5682
setState(() {
5783
_locationChecked = true;
@@ -61,66 +87,127 @@ class _PermissionsPageState extends State<PermissionsPage> {
6187
}
6288
}
6389

90+
bool _locationRequesting = false;
91+
bool _bluetoothRequesting = false;
92+
6493
Future<void> _requestLocation() async {
94+
if (_locationRequesting) return;
95+
setState(() => _locationRequesting = true);
96+
6597
try {
66-
// Check current status first — if permanently denied, open settings
6798
final current = await Permission.locationWhenInUse.status;
99+
debugPrint('Location permission status before request: $current');
100+
68101
if (current.isPermanentlyDenied) {
69102
await openAppSettings();
103+
if (mounted) setState(() => _locationRequesting = false);
104+
// Re-check after returning from settings
105+
_recheckAfterDelay();
70106
return;
71107
}
72108

73109
final status = await Permission.locationWhenInUse.request();
110+
debugPrint('Location permission status after request: $status');
111+
74112
if (mounted) {
75-
setState(() => _locationGranted = status.isGranted);
113+
setState(() {
114+
_locationGranted = status.isGranted;
115+
_locationRequesting = false;
116+
});
76117
}
118+
77119
if (status.isGranted) {
78120
notifySuccess();
79121
} else if (status.isPermanentlyDenied) {
80-
// User denied with "Don't Allow" — must go to Settings
81-
if (mounted) {
82-
_showSettingsSnackbar('Location');
83-
}
122+
if (mounted) _showSettingsSnackbar('Location');
84123
}
85124
} catch (e) {
86125
debugPrint('Location permission error: $e');
87-
// Fallback: try opening settings directly
126+
if (mounted) setState(() => _locationRequesting = false);
88127
await openAppSettings();
89128
}
90129
}
91130

92131
Future<void> _requestBluetooth() async {
132+
if (_bluetoothRequesting) return;
133+
setState(() => _bluetoothRequesting = true);
134+
93135
try {
94-
// Check if already permanently denied
95-
final current = await Permission.bluetoothScan.status;
96-
if (current.isPermanentlyDenied) {
97-
await openAppSettings();
98-
return;
99-
}
136+
if (Platform.isIOS) {
137+
// iOS: single bluetooth permission
138+
final current = await Permission.bluetooth.status;
139+
debugPrint('BT permission status before request (iOS): $current');
100140

101-
final statuses = await [
102-
Permission.bluetoothScan,
103-
Permission.bluetoothConnect,
104-
Permission.bluetoothAdvertise,
105-
].request();
141+
if (current.isPermanentlyDenied) {
142+
await openAppSettings();
143+
if (mounted) setState(() => _bluetoothRequesting = false);
144+
_recheckAfterDelay();
145+
return;
146+
}
147+
148+
final status = await Permission.bluetooth.request();
149+
debugPrint('BT permission status after request (iOS): $status');
106150

107-
final allGranted = statuses.values.every((s) => s.isGranted);
108-
if (mounted) {
109-
setState(() => _bluetoothGranted = allGranted);
110-
}
111-
if (allGranted) {
112-
notifySuccess();
113-
} else if (statuses.values.any((s) => s.isPermanentlyDenied)) {
114151
if (mounted) {
115-
_showSettingsSnackbar('Bluetooth');
152+
setState(() {
153+
_bluetoothGranted = status.isGranted;
154+
_bluetoothRequesting = false;
155+
});
156+
}
157+
158+
if (status.isGranted) {
159+
notifySuccess();
160+
} else if (status.isPermanentlyDenied) {
161+
if (mounted) _showSettingsSnackbar('Bluetooth');
162+
}
163+
} else {
164+
// Android: granular bluetooth permissions
165+
final current = await Permission.bluetoothScan.status;
166+
debugPrint('BT permission status before request (Android): $current');
167+
168+
if (current.isPermanentlyDenied) {
169+
await openAppSettings();
170+
if (mounted) setState(() => _bluetoothRequesting = false);
171+
_recheckAfterDelay();
172+
return;
173+
}
174+
175+
final statuses = await [
176+
Permission.bluetoothScan,
177+
Permission.bluetoothConnect,
178+
Permission.bluetoothAdvertise,
179+
].request();
180+
181+
final allGranted = statuses.values.every((s) => s.isGranted);
182+
debugPrint('BT permissions after request (Android): $statuses');
183+
184+
if (mounted) {
185+
setState(() {
186+
_bluetoothGranted = allGranted;
187+
_bluetoothRequesting = false;
188+
});
189+
}
190+
191+
if (allGranted) {
192+
notifySuccess();
193+
} else if (statuses.values.any((s) => s.isPermanentlyDenied)) {
194+
if (mounted) _showSettingsSnackbar('Bluetooth');
116195
}
117196
}
118197
} catch (e) {
119198
debugPrint('Bluetooth permission error: $e');
199+
if (mounted) setState(() => _bluetoothRequesting = false);
120200
await openAppSettings();
121201
}
122202
}
123203

204+
/// Re-check permissions after a short delay (user returning from Settings)
205+
void _recheckAfterDelay() {
206+
Future.delayed(const Duration(seconds: 1), () {
207+
if (mounted) _checkPermissions();
208+
});
209+
}
210+
124211
void _showSettingsSnackbar(String permission) {
125212
ScaffoldMessenger.of(context).showSnackBar(
126213
SnackBar(
@@ -180,6 +267,7 @@ class _PermissionsPageState extends State<PermissionsPage> {
180267
'and sharing your location with nearby team members.',
181268
isGranted: _locationGranted,
182269
isChecked: _locationChecked,
270+
isRequesting: _locationRequesting,
183271
colors: colors,
184272
onRequest: _requestLocation,
185273
),
@@ -195,6 +283,7 @@ class _PermissionsPageState extends State<PermissionsPage> {
195283
'discovery and communication with nearby devices.',
196284
isGranted: _bluetoothGranted,
197285
isChecked: _bluetoothChecked,
286+
isRequesting: _bluetoothRequesting,
198287
colors: colors,
199288
onRequest: _requestBluetooth,
200289
),
@@ -236,6 +325,7 @@ class _PermissionCard extends StatelessWidget {
236325
required this.description,
237326
required this.isGranted,
238327
required this.isChecked,
328+
required this.isRequesting,
239329
required this.colors,
240330
required this.onRequest,
241331
});
@@ -245,6 +335,7 @@ class _PermissionCard extends StatelessWidget {
245335
final String description;
246336
final bool isGranted;
247337
final bool isChecked;
338+
final bool isRequesting;
248339
final TacticalColorScheme colors;
249340
final VoidCallback onRequest;
250341

@@ -271,13 +362,28 @@ class _PermissionCard extends StatelessWidget {
271362
Text(description, style: TacticalTextStyles.caption(colors)),
272363
if (isChecked && !isGranted) ...[
273364
const SizedBox(height: 12),
274-
TacticalButton(
275-
label: 'Grant',
276-
icon: Icons.shield,
277-
colors: colors,
278-
isCompact: true,
279-
onPressed: onRequest,
280-
),
365+
if (isRequesting)
366+
SizedBox(
367+
height: 44,
368+
child: Center(
369+
child: SizedBox(
370+
width: 24,
371+
height: 24,
372+
child: CircularProgressIndicator(
373+
strokeWidth: 2,
374+
color: colors.accent,
375+
),
376+
),
377+
),
378+
)
379+
else
380+
TacticalButton(
381+
label: 'Grant',
382+
icon: Icons.shield,
383+
colors: colors,
384+
isCompact: true,
385+
onPressed: onRequest,
386+
),
281387
],
282388
],
283389
),

lib/ui/screens/tools/tools_screen.dart

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -203,17 +203,15 @@ class _ToolCard extends StatelessWidget {
203203
size: 28,
204204
),
205205
const SizedBox(height: 8),
206-
FittedBox(
207-
fit: BoxFit.scaleDown,
208-
child: Text(
209-
tool.label,
210-
style: TacticalTextStyles.buttonText(colors).copyWith(
211-
fontSize: 11,
212-
height: 1.2,
213-
),
214-
textAlign: TextAlign.center,
215-
maxLines: 2,
206+
Text(
207+
tool.label,
208+
style: TacticalTextStyles.buttonText(colors).copyWith(
209+
fontSize: 11,
210+
height: 1.2,
216211
),
212+
textAlign: TextAlign.center,
213+
maxLines: 2,
214+
overflow: TextOverflow.visible,
217215
),
218216
const SizedBox(height: 4),
219217
Flexible(

0 commit comments

Comments
 (0)