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:
EdisonJwa
2026-05-18 12:48:28 +08:00
parent 0dc8297568
commit c4145a8727
5 changed files with 702 additions and 0 deletions
@@ -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),
),
],
),
),
);
}
}