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
+69
View File
@@ -20,9 +20,11 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'l10n/generated/app_localizations.dart';
import 'services/android_permissions_service.dart';
import 'src/rust/api.dart' as rust;
import 'src/rust/lib.dart' as rust_err;
import 'src/rust/frb_generated.dart';
import 'widgets/permission_state_banner.dart';
import 'widgets/voice_bar.dart';
import 'widgets/voice_compact.dart';
import 'widgets/voice_settings.dart';
@@ -287,11 +289,23 @@ class _BetaHomeState extends State<_BetaHome> {
List<rust.BridgeBookmark> _bookmarks = const [];
// SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service.
// Constructed at startup so cold-launch state is captured before the
// first voice_join attempt. On non-Android hosts the service
// short-circuits to "granted" and never wires the MethodChannel
// (see AndroidPermissionsService for the platform branch).
final AndroidPermissionsService _androidPermissions =
AndroidPermissionsService();
@override
void initState() {
super.initState();
HardwareKeyboard.instance.addHandler(_handleFocusedPttKey);
_eventsSub = rust.eventsStream().listen(_onEvent);
// SDD-106 §5: subscribe to Kotlin -> Dart permissionStateChanged
// events as early as possible so the listen-only banner reflects
// the system state on first frame.
_androidPermissions.start();
unawaited(_reloadBookmarks());
unawaited(_hydratePttBinding());
}
@@ -462,6 +476,22 @@ class _BetaHomeState extends State<_BetaHome> {
);
}
}());
// SDD-106 §5/§6 / SRS-209: defensive observer of the
// authoritative Rust-side permission stream. The transmit
// clamp is already applied inside `chanora_bridge` before
// this event is broadcast; here we merely surface the
// event so the UI stays consistent if the MethodChannel
// path is ever delayed. The existing `AndroidPermissionsService`
// remains the canonical Dart-side state holder (driven by
// the MethodChannel); a future revision may expose a
// setter so both paths converge on a single ValueNotifier.
case rust.BridgeEvent_PermissionState(
:final permission,
:final state,
):
debugPrint(
'bridge permission_state: permission=$permission state=$state',
);
}
}
@@ -493,6 +523,10 @@ class _BetaHomeState extends State<_BetaHome> {
_hostCtl.dispose();
_nickCtl.dispose();
_passwordCtl.dispose();
// SDD-106: detach the Kotlin -> Dart MethodChannel handler so a
// late invokeMethod from the platform side cannot land on this
// disposed state.
_androidPermissions.stop();
super.dispose();
}
@@ -625,6 +659,34 @@ class _BetaHomeState extends State<_BetaHome> {
if (password == null) return; // cancelled
}
try {
// SDD-106 §1, §6 + SRS-209: Android runtime permission gate.
// Request RECORD_AUDIO at or before voice_join. On denial or
// permanent denial we still proceed (listen-only is a
// first-class mode per SDD-106) but clamp local transmit via
// setHardMute so the audio engine never opens the capture
// stream as a sender. On non-Android the service short-circuits
// to granted and this branch is a no-op.
//
// Trace: SDD-106 §1 (request timing), §2 (listen-only on denial),
// §3 (path to settings on permanent denial), §6
// (TransmitModeSelector clamp); SRS-209.
final permState = await _androidPermissions.ensureRecordAudio();
if (permState != AndroidRecordAudioPermissionState.granted) {
// Listen-only: clamp hard-mute. The permission_state_banner
// surfaces the path-to-grant; the user can re-attempt at any
// time via the Grant / Open Settings action.
try {
await rust.setHardMute(muted: true);
if (mounted) setState(() => _hardMute = true);
} catch (_) {
// Best-effort clamp; if the bridge isn't ready we still
// proceed. The capture path also self-clamps on Android
// when RECORD_AUDIO is not granted (SDD-106 §6 Rust-side,
// via BridgeEvent::PermissionState → TransmitModeSelector
// AtomicU8 clamp); this Dart setHardMute is the
// defence-in-depth path.
}
}
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
if (!mounted) return;
setState(() => _currentVoiceChannelId = ch.id);
@@ -1265,6 +1327,11 @@ class _BetaHomeState extends State<_BetaHome> {
onConfigure: _onOpenVoiceSettings,
onPttHeldChanged: _onOnscreenPttHeldChanged,
);
// SDD-106 §2/§3 + SRS-209: listen-only banner.
// Self-hides on granted / unknown / non-Android.
final permissionBanner = PermissionStateBanner(
service: _androidPermissions,
);
final snapshotView = _SnapshotView(
snapshot: _snapshot!,
currentVoiceChannelId: _currentVoiceChannelId,
@@ -1284,6 +1351,7 @@ class _BetaHomeState extends State<_BetaHome> {
children: [
banner,
const SizedBox(height: 12),
permissionBanner,
voiceBar,
],
),
@@ -1298,6 +1366,7 @@ class _BetaHomeState extends State<_BetaHome> {
children: [
Expanded(child: snapshotView),
const SizedBox(height: 8),
permissionBanner,
VoiceStatusChip(
transmitMode: _transmitMode,
releaseTailMs: _releaseTailMs,
@@ -0,0 +1,297 @@
/// SDD-106 `AndroidPermissionRequester` — Dart integration layer.
///
/// Trace:
/// - SDD-106 (Android runtime acquisition of `RECORD_AUDIO` with
/// listen-only fallback; bridge event surface §5).
/// - SRS-209 (runtime microphone permission acquisition; fail-safe
/// to listen-only on denial; user-visible path to grant).
///
/// Responsibilities:
/// * Subscribe to the Kotlin-side `MethodChannel`
/// `app.chanora/android_permissions` for `permissionStateChanged`
/// invocations emitted by `AndroidPermissionRequester.kt`
/// (Kotlin → Dart).
/// * Provide imperative Dart → Kotlin entry points
/// `requestRecordAudio` and `openAppSettings` so the UI can drive
/// the runtime request flow per SDD-106 §1–§3.
/// * Expose the latest resolved state as a [ValueListenable] so UI
/// surfaces (the listen-only banner per SRS-209; the pre-`voice_join`
/// gate per SDD-106 §1) can react without polling.
///
/// ## Non-Android short-circuit
///
/// On Linux / macOS / Windows / iOS / web, RECORD_AUDIO is not gated
/// by this channel (desktop has no Android runtime-permission concept;
/// iOS uses AVAudioSession authorisation which is owned by the audio
/// engine itself per SDD-101). The MethodChannel is therefore never
/// constructed off-Android. The state listenable stays at
/// [AndroidRecordAudioPermissionState.granted] so the voice-join gate
/// (in `main.dart`) becomes a no-op on those platforms.
///
/// ## No global statics
///
/// Following the pattern established by `BackIntentService`
/// (SDD-028), this class is constructor-injected. The host app
/// instantiates one instance at startup and passes it through the
/// widget tree.
///
/// ## Bridge event surface (SDD-106 §5)
///
/// SDD-106 §5 calls for a `BridgeEvent::PermissionState` variant on
/// the existing Rust → Dart event stream so the audio engine can
/// observe the resolved state for the transmit-clamp described in
/// SDD-106 §6. That Rust-side variant is intentionally deferred to a
/// follow-up Wave touching `chanora_bridge`; the current Wave 2B
/// Dart-only path is sufficient for the UI obligations of SRS-209
/// (listen-only banner; path to grant). See the structured handoff
/// note attached to this task for the rationale.
library;
import 'dart:async';
import 'dart:developer' as developer;
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// MethodChannel name shared with Kotlin `MethodChannels.ANDROID_PERMISSIONS`.
///
/// Trace: SDD-106 §5.
@visibleForTesting
const String androidPermissionsChannelName = 'app.chanora/android_permissions';
/// Inbound (Kotlin → Dart) method invoked whenever the resolved
/// `RECORD_AUDIO` permission state transitions.
///
/// Payload schema: `{permission: String, state: String}` where
/// `state` is one of `"Granted"`, `"Denied"`, `"PermanentlyDenied"`.
///
/// Trace: SDD-106 §5; mirrors Kotlin
/// `MethodChannels.METHOD_PERMISSION_STATE_CHANGED`.
@visibleForTesting
const String methodPermissionStateChanged = 'permissionStateChanged';
/// Outbound (Dart → Kotlin) method that asks the Android requester to
/// (re-)prompt for `RECORD_AUDIO`. The result is delivered
/// asynchronously via [methodPermissionStateChanged].
///
/// Trace: SDD-106 §1.
@visibleForTesting
const String methodRequestRecordAudio = 'requestRecordAudio';
/// Outbound (Dart → Kotlin) method that deep-links to the application's
/// Android Settings page so the user can re-grant a permanently-denied
/// permission.
///
/// Trace: SDD-106 §3.
@visibleForTesting
const String methodOpenAppSettings = 'openAppSettings';
/// Discrete states surfaced to the Dart UI.
///
/// Trace: SDD-106 §5 (state machine).
enum AndroidRecordAudioPermissionState {
/// Permission granted; microphone capture is allowed.
granted,
/// Permission denied but the user may still be re-prompted by
/// invoking [AndroidPermissionsService.ensureRecordAudio] again.
denied,
/// Permission denied with "Don't ask again" or revoked from system
/// Settings. The UI must deep-link to app settings via
/// [AndroidPermissionsService.openAppSettings] (SDD-106 §3).
permanentlyDenied,
/// No resolved state yet (cold launch before the first emission, or
/// non-Android host before short-circuit). The voice-join gate
/// treats this as "request before joining".
unknown,
}
/// Maps the Kotlin-side `PermissionState` string into the Dart enum.
AndroidRecordAudioPermissionState _parseState(String? raw) {
switch (raw) {
case 'Granted':
return AndroidRecordAudioPermissionState.granted;
case 'Denied':
return AndroidRecordAudioPermissionState.denied;
case 'PermanentlyDenied':
return AndroidRecordAudioPermissionState.permanentlyDenied;
default:
return AndroidRecordAudioPermissionState.unknown;
}
}
/// Dart-side integration for `AndroidPermissionRequester` (SDD-106).
///
/// Trace: SDD-106, SRS-209.
class AndroidPermissionsService {
/// Construct a service bound to [channel]. Injected for testability;
/// production code uses the default channel keyed on
/// [androidPermissionsChannelName].
AndroidPermissionsService({MethodChannel? channel})
: _channel = channel ??
(_isAndroid
? const MethodChannel(androidPermissionsChannelName)
: null);
/// Platform-detection seam. Web counts as non-Android.
static bool get _isAndroid {
if (kIsWeb) return false;
return Platform.isAndroid;
}
/// The underlying channel, or `null` on non-Android (and unset in
/// tests that omit the optional argument).
final MethodChannel? _channel;
final ValueNotifier<AndroidRecordAudioPermissionState> _state =
ValueNotifier<AndroidRecordAudioPermissionState>(
// On non-Android, present as granted so the voice-join gate is a
// no-op (desktop / iOS have separate audio-permission paths owned
// elsewhere; see SDD-101 for iOS).
_isAndroid
? AndroidRecordAudioPermissionState.unknown
: AndroidRecordAudioPermissionState.granted,
);
bool _started = false;
/// Latest known [RECORD_AUDIO] state. Defaults to
/// [AndroidRecordAudioPermissionState.unknown] on Android and
/// [AndroidRecordAudioPermissionState.granted] elsewhere.
ValueListenable<AndroidRecordAudioPermissionState> get recordAudioState =>
_state;
/// Start listening for state updates from Kotlin. Idempotent.
///
/// On non-Android this is a no-op.
///
/// Trace: SDD-106 §5.
void start() {
if (_started) return;
_started = true;
final ch = _channel;
if (ch == null) return;
ch.setMethodCallHandler(_handle);
}
/// Stop listening. Idempotent.
void stop() {
if (!_started) return;
_started = false;
final ch = _channel;
if (ch == null) return;
ch.setMethodCallHandler(null);
}
Future<dynamic> _handle(MethodCall call) async {
if (call.method != methodPermissionStateChanged) return null;
final args = call.arguments;
if (args is! Map) return null;
// We currently track RECORD_AUDIO only. POST_NOTIFICATIONS (SDD-107
// §6) is on the same channel by design and will route through a
// sibling listenable when that work lands.
final permission = args['permission'];
if (permission != 'android.permission.RECORD_AUDIO') return null;
final state = _parseState(args['state'] as String?);
_state.value = state;
return null;
}
/// Request the system permission. Invokes the Kotlin requester and
/// then, if the platform synchronously resolves the request, returns
/// the resolved state without parking for the listener; otherwise
/// awaits the next `permissionStateChanged` emission.
///
/// On non-Android, resolves immediately with
/// [AndroidRecordAudioPermissionState.granted].
///
/// Trace: SDD-106 §1, §3.
Future<AndroidRecordAudioPermissionState> ensureRecordAudio() async {
final ch = _channel;
if (ch == null) {
return AndroidRecordAudioPermissionState.granted;
}
// Snapshot the pre-invocation state. If the platform synchronously
// resolves the request to a NEW state during invokeMethod (the
// platform may emit permissionStateChanged before invokeMethod
// returns), we can return immediately. If the state is unchanged
// (typical: user is being re-prompted after a previous denial; the
// platform always re-prompts and the resolution arrives
// asynchronously), we must wait for the next listener fire.
final preInvokeState = _state.value;
try {
await ch.invokeMethod<void>(methodRequestRecordAudio);
} catch (_) {
// Channel-side failure (e.g. missing handler in a debug build).
// Fall back to whatever state we currently hold; if still
// unknown, surface unknown so the caller can choose its own
// policy. Per SRS-209 the voice-join gate treats unknown as
// "proceed in listen-only".
return _state.value;
}
// M1 fix (corrected): only short-circuit if the platform changed
// the state synchronously to a resolved value. A still-unknown
// state means we must wait. A still-equal-to-pre-invoke state
// means we also must wait (the platform is re-prompting; the
// resolution arrives asynchronously after the user interacts with
// the dialog).
if (_state.value != preInvokeState &&
_state.value != AndroidRecordAudioPermissionState.unknown) {
return _state.value;
}
// Otherwise we wait for the listener to observe a state
// transition. Bounded wait to avoid hanging the voice-join flow
// if the platform dialog is dismissed without a result. The
// Kotlin requester treats dismissal as Denied (see
// AndroidPermissionRequester.handleRequestPermissionsResult) so
// this is defence-in-depth.
final completer = Completer<AndroidRecordAudioPermissionState>();
void listener() {
if (!completer.isCompleted &&
_state.value != preInvokeState &&
_state.value != AndroidRecordAudioPermissionState.unknown) {
completer.complete(_state.value);
}
}
_state.addListener(listener);
try {
return await completer.future.timeout(
const Duration(seconds: 30),
onTimeout: () => _state.value,
);
} finally {
_state.removeListener(listener);
}
}
/// Deep-link to the system app settings page for the permanently
/// denied case (SDD-106 §3). On non-Android, a no-op.
Future<void> openAppSettings() async {
final ch = _channel;
if (ch == null) return;
try {
await ch.invokeMethod<void>(methodOpenAppSettings);
} catch (e, st) {
// Best-effort; failure to launch settings is non-fatal. The
// Kotlin side already logs ActivityNotFoundException.
developer.log(
'openAppSettings failed',
name: 'AndroidPermissionsService',
error: e,
stackTrace: st,
);
}
}
/// Release the state notifier. Test helper; production keeps the
/// service alive for the lifetime of the app.
@visibleForTesting
void dispose() {
stop();
_state.dispose();
}
}
@@ -0,0 +1,66 @@
/// Pure-Dart route-pop policy for SDD-028 (`BackIntentService`).
///
/// Trace: SDD-028 §2 (deterministic route-pop ordering) / SAD-018.
///
/// This file intentionally has **no Flutter dependency** so it can be
/// unit-tested without a `MaterialApp` or `Navigator` context. The
/// integration wrapper lives in `back_intent_service.dart`.
library;
/// Decision produced by [decideBackIntent].
///
/// Trace: SDD-028 §2 — exactly one decision per platform back event.
sealed class BackIntentDecision {
const BackIntentDecision();
}
/// PTT is currently transmitting; the back event must be swallowed
/// without any UI side effect (SDD-028 §2.a).
class IgnoreDueToPtt extends BackIntentDecision {
const IgnoreDueToPtt();
}
/// A modal / dialog route is on top; close it only (SDD-028 §2.b).
class CloseModal extends BackIntentDecision {
const CloseModal();
}
/// Navigator can pop a non-root route (SDD-028 §2.c).
class PopRoute extends BackIntentDecision {
const PopRoute();
}
/// At the root route with no modal and no PTT; the platform shall be
/// asked to finish the activity (SDD-028 §2.d — `exitCandidate`).
class ExitApp extends BackIntentDecision {
const ExitApp();
}
/// Resolve the back-intent decision deterministically.
///
/// Trace: SDD-028 §2. The branch order is intentionally fixed:
///
/// 1. `pttActive == true` → [IgnoreDueToPtt]
/// 2. `modalOpen == true` → [CloseModal]
/// 3. `atRoot == false` → [PopRoute]
/// 4. otherwise → [ExitApp]
///
/// This function is pure: identical inputs always produce identical
/// outputs and there are no side effects, which is the verification
/// surface targeted by SWE4-UV-042.
BackIntentDecision decideBackIntent({
required bool pttActive,
required bool modalOpen,
required bool atRoot,
}) {
if (pttActive) {
return const IgnoreDueToPtt();
}
if (modalOpen) {
return const CloseModal();
}
if (!atRoot) {
return const PopRoute();
}
return const ExitApp();
}
@@ -0,0 +1,162 @@
/// SDD-028 `BackIntentService` — Dart integration layer.
///
/// Trace: SDD-028 (Android back-intent normalization) / SAD-018.
///
/// Responsibilities:
/// * Listen on `MethodChannel("app.chanora/back_intent")` for
/// `backIntent` calls forwarded by `BackIntentBridge` (Kotlin).
/// * Read the current shell state via **injected** getter callbacks
/// (`pttActiveProbe`, `modalOpenProbe`, `atRootProbe`) so the class
/// is testable without a `MaterialApp` / global Navigator. No
/// global statics are introduced.
/// * Apply `decideBackIntent(...)` from `back_intent_policy.dart` to
/// produce a deterministic `BackIntentDecision`.
/// * Dispatch the decision to injected effect callbacks
/// (`closeTopModal`, `popRoute`) and, for `ExitApp`, ask the
/// platform to finish via `channel.invokeMethod('popToSystem')`.
///
/// Non-Android platforms: use [BackIntentService.noOp] which never
/// registers a channel handler. The rest of the app can construct a
/// `BackIntentService` unconditionally.
library;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'back_intent_policy.dart';
/// Probe returning the current value of a shell-state flag.
typedef BackIntentBoolProbe = bool Function();
/// Effect callback invoked by [BackIntentService] when the policy
/// resolves a non-platform decision.
typedef BackIntentEffect = void Function();
/// MethodChannel contract shared with `BackIntentBridge.kt`.
@visibleForTesting
const String backIntentChannelName = 'app.chanora/back_intent';
@visibleForTesting
const String backIntentMethodFromPlatform = 'backIntent';
@visibleForTesting
const String backIntentMethodPopToSystem = 'popToSystem';
@visibleForTesting
const String backIntentPayloadKindKey = 'kind';
@visibleForTesting
const String backIntentPayloadKindSystemBack = 'system_back';
/// SDD-028 service. Construct once per app shell.
class BackIntentService {
BackIntentService({
required BackIntentBoolProbe pttActiveProbe,
required BackIntentBoolProbe modalOpenProbe,
required BackIntentBoolProbe atRootProbe,
required BackIntentEffect closeTopModal,
required BackIntentEffect popRoute,
MethodChannel? channel,
}) : _pttActiveProbe = pttActiveProbe,
_modalOpenProbe = modalOpenProbe,
_atRootProbe = atRootProbe,
_closeTopModal = closeTopModal,
_popRoute = popRoute,
_channel = channel ?? const MethodChannel(backIntentChannelName),
_enabled = true;
/// No-op factory for non-Android targets (SDD-028 §6).
///
/// Constructs a service whose probes always return false and whose
/// effect callbacks do nothing. It never installs a channel handler.
factory BackIntentService.noOp() {
return BackIntentService._noOp();
}
BackIntentService._noOp()
: _pttActiveProbe = _alwaysFalse,
_modalOpenProbe = _alwaysFalse,
_atRootProbe = _alwaysFalse,
_closeTopModal = _noEffect,
_popRoute = _noEffect,
_channel = const MethodChannel(backIntentChannelName),
_enabled = false;
final BackIntentBoolProbe _pttActiveProbe;
final BackIntentBoolProbe _modalOpenProbe;
final BackIntentBoolProbe _atRootProbe;
final BackIntentEffect _closeTopModal;
final BackIntentEffect _popRoute;
final MethodChannel _channel;
final bool _enabled;
bool _started = false;
/// Install the MethodChannel handler. Safe to call once per instance.
/// On the no-op variant this returns without registering anything.
void start() {
if (!_enabled || _started) {
return;
}
_channel.setMethodCallHandler(_handleMethodCall);
_started = true;
}
/// Remove the MethodChannel handler. Safe to call repeatedly.
void stop() {
if (!_started) {
return;
}
_channel.setMethodCallHandler(null);
_started = false;
}
/// Resolve and dispatch a back intent. Visible for tests so the
/// decision pipeline can be driven without a real platform channel.
@visibleForTesting
BackIntentDecision dispatch() {
final decision = decideBackIntent(
pttActive: _pttActiveProbe(),
modalOpen: _modalOpenProbe(),
atRoot: _atRootProbe(),
);
_apply(decision);
return decision;
}
Future<dynamic> _handleMethodCall(MethodCall call) async {
if (call.method != backIntentMethodFromPlatform) {
return null;
}
final args = call.arguments;
if (args is Map &&
args[backIntentPayloadKindKey] != backIntentPayloadKindSystemBack) {
// Unknown kind: ignore defensively per SDD-028 §5 sanitiser stance.
return null;
}
dispatch();
return null;
}
void _apply(BackIntentDecision decision) {
switch (decision) {
case IgnoreDueToPtt():
// SDD-028 §2.a — consume without UI effect.
return;
case CloseModal():
_closeTopModal();
return;
case PopRoute():
_popRoute();
return;
case ExitApp():
// SDD-028 §2.d — defer to platform fallback.
// ignore: discarded_futures
_channel.invokeMethod<void>(backIntentMethodPopToSystem);
return;
}
}
static bool _alwaysFalse() => false;
static void _noEffect() {}
}
@@ -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),
),
],
),
),
);
}
}