feat(flutter,android): permission state banner + AndroidPermissionsService + voice-join gate
Dart consumer for the Android permission state pipeline. New AndroidPermissionsService listens on the app.chanora/android_permissions MethodChannel and exposes a ValueListenable for the UI. The voice-join flow in main.dart calls ensureRecordAudio() before rust.voiceJoin and clamps to listen-only via setHardMute on denial. A non-modal banner above the VoiceBar surfaces the Grant / Open Settings action depending on whether the state is Denied or PermanentlyDenied. On non-Android hosts the service short-circuits to granted; the banner is never built. Also adds the BackIntentService Dart consumer (back_intent_policy + back_intent_service) which the Kotlin BackIntentBridge invokes via MethodChannel for deterministic route-pop ordering. Trace: SDD-028, SDD-106, SRS-163, SRS-209.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
/// SRS-209 listen-only banner for Android RECORD_AUDIO permission.
|
||||
///
|
||||
/// Trace:
|
||||
/// - SDD-106 §2 (denial UX — non-blocking affordance "Enable
|
||||
/// microphone"), §3 (permanent denial — "Open settings" deep-link).
|
||||
/// - SRS-209 (path to grant; listen-only fallback).
|
||||
///
|
||||
/// Behaviour:
|
||||
/// * Watches [AndroidPermissionsService.recordAudioState].
|
||||
/// * On `denied`: renders a non-modal banner with a "Grant" action.
|
||||
/// * On `permanentlyDenied`: action text becomes "Open Settings" and
|
||||
/// invokes [AndroidPermissionsService.openAppSettings].
|
||||
/// * On `granted` / `unknown`: builds an empty [SizedBox.shrink].
|
||||
/// * On non-Android hosts the service stays at `granted`, so this
|
||||
/// widget is effectively invisible without any extra branching.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/android_permissions_service.dart';
|
||||
|
||||
/// Listen-only banner widget. Drop this above the `VoiceBar` in the
|
||||
/// app shell; it self-hides when no action is required.
|
||||
///
|
||||
/// Trace: SDD-106 §2, §3; SRS-209.
|
||||
class PermissionStateBanner extends StatelessWidget {
|
||||
const PermissionStateBanner({super.key, required this.service});
|
||||
|
||||
/// Permissions service whose [AndroidPermissionsService.recordAudioState]
|
||||
/// drives the banner.
|
||||
final AndroidPermissionsService service;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<AndroidRecordAudioPermissionState>(
|
||||
valueListenable: service.recordAudioState,
|
||||
builder: (ctx, state, _) {
|
||||
switch (state) {
|
||||
case AndroidRecordAudioPermissionState.granted:
|
||||
case AndroidRecordAudioPermissionState.unknown:
|
||||
return const SizedBox.shrink();
|
||||
case AndroidRecordAudioPermissionState.denied:
|
||||
return _BannerBody(
|
||||
// TODO(localization): route through AppL10n once an arb
|
||||
// entry exists. SRS-209 requires the message; the
|
||||
// English literal is a placeholder.
|
||||
message:
|
||||
'Microphone permission required for voice transmission.',
|
||||
actionLabel: 'Grant',
|
||||
onPressed: () => service.ensureRecordAudio(),
|
||||
);
|
||||
case AndroidRecordAudioPermissionState.permanentlyDenied:
|
||||
return _BannerBody(
|
||||
// TODO(localization): see above.
|
||||
message:
|
||||
'Microphone permission required for voice transmission.',
|
||||
actionLabel: 'Open Settings',
|
||||
onPressed: () => service.openAppSettings(),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BannerBody extends StatelessWidget {
|
||||
const _BannerBody({
|
||||
required this.message,
|
||||
required this.actionLabel,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final String actionLabel;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Material(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.mic_off, color: theme.colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(color: theme.colorScheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: onPressed,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
child: Text(actionLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user