callwave_flutter 0.3.1 copy "callwave_flutter: ^0.3.1" to clipboard
callwave_flutter: ^0.3.1 copied to clipboard

Flutter plugin for WhatsApp-style or Google Meet/Microsoft Teams-style VoIP call UX. Incoming/outgoing notifications, accept/decline/timeout, CallKit and Android full-screen intents.

example/lib/main.dart

import 'dart:async';
import 'dart:io';

import 'package:callwave_flutter/callwave_flutter.dart';
import 'package:flutter/material.dart';

import 'example_camera_controller.dart';
import 'example_video_call_screen.dart';
import 'mock_callwave_engine.dart';

enum IncomingDemoMode {
  realtime('Realtime'),
  validatedAllow('Validated Allow'),
  validatedReject('Validated Reject'),
  declineReported('Decline Reported'),
  declineFailed('Decline Failed');

  const IncomingDemoMode(this.label);

  final String label;
}

enum CallbackSessionMode {
  oneToOne('One-to-one'),
  conference('Conference');

  const CallbackSessionMode(this.label);

  final String label;
}

abstract final class _ExampleExtraKeys {
  static const String roomType = 'roomType';
  static const String roomTypeOneToOne = 'oneToOne';
  static const String roomTypeConference = 'conference';
}

class _IncomingDemoModeStore {
  static File get _file => File(
        '${Directory.systemTemp.path}/callwave_example_incoming_mode.txt',
      );

  static Future<IncomingDemoMode> load() async {
    try {
      final raw = (await _file.readAsString()).trim();
      return IncomingDemoMode.values.firstWhere(
        (mode) => mode.name == raw,
        orElse: () => IncomingDemoMode.realtime,
      );
    } catch (_) {
      return IncomingDemoMode.realtime;
    }
  }

  static Future<void> save(IncomingDemoMode mode) async {
    await _file.writeAsString(mode.name, flush: true);
  }

  static Future<void> clear() async {
    if (await _file.exists()) {
      await _file.delete();
    }
  }
}

Future<IncomingDemoMode> loadPersistedIncomingDemoMode() {
  return _IncomingDemoModeStore.load();
}

Future<void> persistIncomingDemoMode(IncomingDemoMode mode) {
  return _IncomingDemoModeStore.save(mode);
}

Future<void> clearPersistedIncomingDemoMode() {
  return _IncomingDemoModeStore.clear();
}

IncomingCallHandling exampleIncomingCallHandling({
  required IncomingDemoMode incomingDemoMode,
  required CallAcceptValidator foregroundValidator,
}) {
  return switch (incomingDemoMode) {
    IncomingDemoMode.realtime ||
    IncomingDemoMode.declineReported ||
    IncomingDemoMode.declineFailed =>
      const IncomingCallHandling.realtime(),
    IncomingDemoMode.validatedAllow ||
    IncomingDemoMode.validatedReject =>
      IncomingCallHandling.validated(
        validator: foregroundValidator,
      ),
  };
}

void configureExampleCallwave({
  required CallwaveEngine engine,
  required IncomingDemoMode incomingDemoMode,
  required CallAcceptValidator foregroundValidator,
}) {
  CallwaveFlutter.instance.configure(
    CallwaveConfiguration(
      engine: engine,
      incomingCallHandling: exampleIncomingCallHandling(
        incomingDemoMode: incomingDemoMode,
        foregroundValidator: foregroundValidator,
      ),
      backgroundIncomingCallValidator: exampleBackgroundIncomingCallValidator,
      backgroundIncomingCallDeclineValidator:
          exampleBackgroundIncomingCallDeclineValidator,
    ),
  );
}

Future<CallAcceptDecision> _decisionForMode({
  required IncomingDemoMode mode,
  required String callId,
}) async {
  await Future<void>.delayed(const Duration(milliseconds: 700));
  switch (mode) {
    case IncomingDemoMode.realtime:
    case IncomingDemoMode.validatedAllow:
    case IncomingDemoMode.declineReported:
    case IncomingDemoMode.declineFailed:
      return CallAcceptDecision.allow(
        extra: <String, dynamic>{
          'validatedByExample': true,
          'validatedCallId': callId,
        },
      );
    case IncomingDemoMode.validatedReject:
      return const CallAcceptDecision.reject(
        reason: CallAcceptRejectReason.cancelled,
        extra: <String, dynamic>{
          'validatedByExample': true,
        },
      );
  }
}

Future<CallDeclineDecision> _declineDecisionForMode({
  required IncomingDemoMode mode,
  required String callId,
}) async {
  await Future<void>.delayed(const Duration(milliseconds: 700));
  switch (mode) {
    case IncomingDemoMode.declineFailed:
      return const CallDeclineDecision.failed(
        reason: CallDeclineFailureReason.failed,
        extra: <String, dynamic>{
          'declineReportedByExample': false,
        },
      );
    case IncomingDemoMode.realtime:
    case IncomingDemoMode.validatedAllow:
    case IncomingDemoMode.validatedReject:
    case IncomingDemoMode.declineReported:
      return CallDeclineDecision.reported(
        extra: <String, dynamic>{
          'declineReportedByExample': true,
          'declineReportedCallId': callId,
        },
      );
  }
}

@pragma('vm:entry-point')
Future<CallAcceptDecision> exampleBackgroundIncomingCallValidator(
  BackgroundIncomingCallValidationRequest request,
) async {
  final mode = await loadPersistedIncomingDemoMode();
  return _decisionForMode(
    mode: mode,
    callId: request.callId,
  );
}

@pragma('vm:entry-point')
Future<CallDeclineDecision> exampleBackgroundIncomingCallDeclineValidator(
  BackgroundIncomingCallValidationRequest request,
) async {
  final mode = await loadPersistedIncomingDemoMode();
  return _declineDecisionForMode(
    mode: mode,
    callId: request.callId,
  );
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final cameraController = ExampleCameraController();
  final engine = MockCallwaveEngine(
    cameraController: cameraController,
  );
  final initialIncomingDemoMode = await loadPersistedIncomingDemoMode();
  configureExampleCallwave(
    engine: engine,
    incomingDemoMode: initialIncomingDemoMode,
    foregroundValidator: (session) {
      return _decisionForMode(
        mode: initialIncomingDemoMode,
        callId: session.callId,
      );
    },
  );
  final startupDecision =
      await CallwaveFlutter.instance.prepareStartupRouteDecision();
  runApp(
    CallwaveExampleApp(
      startupDecision: startupDecision,
      cameraController: cameraController,
      engine: engine,
      initialIncomingDemoMode: initialIncomingDemoMode,
      disposeCameraControllerOnDispose: true,
    ),
  );
}

abstract final class _Routes {
  static const String home = '/home';
  static const String call = '/call';
}

class CallwaveExampleApp extends StatefulWidget {
  const CallwaveExampleApp({
    CallStartupRouteDecision? startupDecision,
    this.cameraController,
    this.engine,
    this.oneToOneRemoteVideoBuilder,
    this.oneToOneLocalVideoBuilder,
    this.initialIncomingDemoMode = IncomingDemoMode.realtime,
    this.disposeCameraControllerOnDispose = false,
    super.key,
  }) : startupDecision =
            startupDecision ?? const CallStartupRouteDecision.home();

  final CallStartupRouteDecision startupDecision;

  /// Handle for camera preview in video calls. If null, a default is created.
  final ExampleCameraHandle? cameraController;
  final CallwaveEngine? engine;
  final OneToOneRemoteVideoBuilder? oneToOneRemoteVideoBuilder;
  final OneToOneLocalVideoBuilder? oneToOneLocalVideoBuilder;
  final IncomingDemoMode initialIncomingDemoMode;

  /// If true and [cameraController] is provided, the app disposes it when
  /// disposed. Use when the app creates the controller (e.g. in main).
  final bool disposeCameraControllerOnDispose;

  @override
  State<CallwaveExampleApp> createState() => _CallwaveExampleAppState();
}

class _CallwaveExampleAppState extends State<CallwaveExampleApp> {
  final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
  late final ExampleCameraHandle _cameraController =
      widget.cameraController ?? ExampleCameraController();
  late final bool _ownsCameraController = widget.cameraController == null ||
      widget.disposeCameraControllerOnDispose;
  late final CallwaveEngine _engine =
      widget.engine ?? MockCallwaveEngine(cameraController: _cameraController);
  late final Set<String> _preRoutedCallIds =
      widget.startupDecision.callId == null
          ? const <String>{}
          : <String>{widget.startupDecision.callId!};

  @override
  void dispose() {
    if (_ownsCameraController) {
      _cameraController.dispose();
    }
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      navigatorKey: _navigatorKey,
      title: 'Callwave Example',
      theme: ThemeData(
        brightness: Brightness.light,
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFFC4441A),
          brightness: Brightness.light,
        ).copyWith(
          primary: const Color(0xFFC4441A),
          onPrimary: Colors.white,
          secondary: const Color(0xFFC4441A),
          onSecondary: Colors.white,
        ),
        scaffoldBackgroundColor: const Color(0xFFFAF8F5),
        cardColor: const Color(0xFFFFFDFB),
        inputDecorationTheme: InputDecorationTheme(
          filled: true,
          fillColor: const Color(0xFFF5F2EE),
          contentPadding:
              const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
          border: OutlineInputBorder(
            borderRadius: BorderRadius.circular(10),
            borderSide: const BorderSide(color: Color(0xFFD9D3CB)),
          ),
          enabledBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(10),
            borderSide: const BorderSide(color: Color(0xFFD9D3CB)),
          ),
          focusedBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(10),
            borderSide: const BorderSide(color: Color(0xFFC4441A), width: 1.5),
          ),
          labelStyle: const TextStyle(color: Color(0xFF7A7267)),
          hintStyle: const TextStyle(color: Color(0xFFB5AFA7)),
          helperStyle: const TextStyle(color: Color(0xFFB5AFA7)),
        ),
        dividerColor: const Color(0xFFE8E3DC),
        useMaterial3: true,
      ),
      builder: (context, child) {
        return CallwaveScope(
          navigatorKey: _navigatorKey,
          preRoutedCallIds: _preRoutedCallIds,
          callScreenBuilder: (context, session) {
            return _buildCallScreen(
              session: session,
              cameraController: _cameraController,
              oneToOneRemoteVideoBuilder: widget.oneToOneRemoteVideoBuilder,
              oneToOneLocalVideoBuilder: widget.oneToOneLocalVideoBuilder,
            );
          },
          child: child ?? const SizedBox.shrink(),
        );
      },
      initialRoute:
          widget.startupDecision.shouldOpenCall ? _Routes.call : _Routes.home,
      routes: <String, WidgetBuilder>{
        _Routes.home: (_) => CallDemoScreen(
              engine: _engine,
              initialIncomingDemoMode: widget.initialIncomingDemoMode,
              initialPendingAction: widget.startupDecision.pendingAction,
            ),
        _Routes.call: (_) => _StartupCallRoute(
              startupDecision: widget.startupDecision,
              cameraController: _cameraController,
              engine: _engine,
              initialIncomingDemoMode: widget.initialIncomingDemoMode,
              oneToOneRemoteVideoBuilder: widget.oneToOneRemoteVideoBuilder,
              oneToOneLocalVideoBuilder: widget.oneToOneLocalVideoBuilder,
            ),
      },
    );
  }
}

class _StartupCallRoute extends StatelessWidget {
  const _StartupCallRoute({
    required this.startupDecision,
    required this.cameraController,
    required this.engine,
    required this.initialIncomingDemoMode,
    this.oneToOneRemoteVideoBuilder,
    this.oneToOneLocalVideoBuilder,
  });

  final CallStartupRouteDecision startupDecision;
  final ExampleCameraHandle cameraController;
  final CallwaveEngine engine;
  final IncomingDemoMode initialIncomingDemoMode;
  final OneToOneRemoteVideoBuilder? oneToOneRemoteVideoBuilder;
  final OneToOneLocalVideoBuilder? oneToOneLocalVideoBuilder;

  @override
  Widget build(BuildContext context) {
    final callId = startupDecision.callId;
    if (callId == null) {
      return CallDemoScreen(
        engine: engine,
        initialIncomingDemoMode: initialIncomingDemoMode,
        initialPendingAction: startupDecision.pendingAction,
      );
    }

    final session = CallwaveFlutter.instance.getSession(callId);
    if (session == null || session.isEnded) {
      return CallDemoScreen(
        engine: engine,
        initialIncomingDemoMode: initialIncomingDemoMode,
        initialPendingAction: startupDecision.pendingAction,
      );
    }

    return InheritedCallSession(
      session: session,
      child: _buildCallScreen(
        session: session,
        cameraController: cameraController,
        oneToOneRemoteVideoBuilder: oneToOneRemoteVideoBuilder,
        oneToOneLocalVideoBuilder: oneToOneLocalVideoBuilder,
        onCallEnded: () {
          Navigator.of(context).pushReplacementNamed(_Routes.home);
        },
      ),
    );
  }
}

Widget _buildCallScreen({
  required CallSession session,
  required ExampleCameraHandle cameraController,
  OneToOneRemoteVideoBuilder? oneToOneRemoteVideoBuilder,
  OneToOneLocalVideoBuilder? oneToOneLocalVideoBuilder,
  VoidCallback? onCallEnded,
}) {
  if (session.callData.callType != CallType.video) {
    return CallScreen(
      session: session,
      onCallEnded: onCallEnded,
      oneToOneRemoteVideoBuilder: oneToOneRemoteVideoBuilder,
      oneToOneLocalVideoBuilder: oneToOneLocalVideoBuilder,
    );
  }
  return ExampleVideoCallScreen(
    session: session,
    cameraController: cameraController,
    oneToOneRemoteVideoBuilder: oneToOneRemoteVideoBuilder,
    oneToOneLocalVideoBuilder: oneToOneLocalVideoBuilder,
    onCallEnded: onCallEnded,
  );
}

class CallDemoScreen extends StatefulWidget {
  const CallDemoScreen({
    required this.engine,
    required this.initialIncomingDemoMode,
    this.initialPendingAction,
    super.key,
  });

  final CallwaveEngine engine;
  final IncomingDemoMode initialIncomingDemoMode;
  final CallStartupAction? initialPendingAction;

  @override
  State<CallDemoScreen> createState() => _CallDemoScreenState();
}

class _CallDemoScreenState extends State<CallDemoScreen> {
  static const String _incomingCallerName = 'Ava';
  static const String _incomingHandle = '+1 555 0101';
  static const String _outgoingCallerName = 'Milo';
  static const String _outgoingHandle = '+1 555 0202';

  final List<String> _eventLog = <String>[];
  final TextEditingController _callIdController =
      TextEditingController(text: 'demo-call-001');
  final TextEditingController _missedNotificationTextController =
      TextEditingController();
  late final CallwaveEngine _engine = widget.engine;
  StreamSubscription<CallEvent>? _subscription;
  bool _isCallActionInFlight = false;
  String? _previewCallId;
  int _speakerCursor = 0;
  late IncomingDemoMode _incomingDemoMode = widget.initialIncomingDemoMode;
  CallStartupAction? _pendingStartupAction;
  CallType _callbackCallType = CallType.audio;
  CallbackSessionMode _callbackSessionMode = CallbackSessionMode.oneToOne;

  @override
  void initState() {
    super.initState();
    _missedNotificationTextController.text = 'You missed a call from {name}.';
    _subscription = CallwaveFlutter.instance.events.listen(_onCallEvent);
    _applyPendingStartupAction(widget.initialPendingAction, logEvent: false);
  }

  @override
  void dispose() {
    _subscription?.cancel();
    _callIdController.dispose();
    _missedNotificationTextController.dispose();
    super.dispose();
  }

  void _onCallEvent(CallEvent event) {
    if (!mounted) {
      return;
    }
    if (event.type == CallEventType.callback) {
      _applyPendingStartupAction(
        _startupActionFromEvent(
          event: event,
          type: CallStartupActionType.callback,
        ),
      );
    } else if (event.type == CallEventType.missed &&
        event.extra?[CallEventExtraKeys.launchAction] ==
            CallEventExtraKeys.launchActionOpenMissedCall) {
      _applyPendingStartupAction(
        _startupActionFromEvent(
          event: event,
          type: CallStartupActionType.openMissedCall,
        ),
      );
    }
    if (_previewCallId == event.callId &&
        (event.type == CallEventType.ended ||
            event.type == CallEventType.declined ||
            event.type == CallEventType.timeout ||
            event.type == CallEventType.missed)) {
      _previewCallId = null;
    }
    setState(() {
      _eventLog.insert(
        0,
        '${event.timestamp.toIso8601String()} ${event.callId} ${event.type.name}',
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    final callId = _callIdController.text.trim();
    final bottomInset = MediaQuery.of(context).viewInsets.bottom;
    final hasActiveSessions =
        CallwaveFlutter.instance.activeSessions.isNotEmpty;
    final incomingModeLocked = hasActiveSessions || _isCallActionInFlight;
    return Scaffold(
      appBar: PreferredSize(
        preferredSize: const Size.fromHeight(64),
        child: Container(
          decoration: const BoxDecoration(
            color: Color(0xFFFFFDFB),
            border: Border(
              bottom: BorderSide(color: Color(0xFFE8E3DC), width: 1),
            ),
          ),
          child: SafeArea(
            bottom: false,
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 18),
              child: Row(
                children: <Widget>[
                  const Icon(
                    Icons.waves_rounded,
                    color: Color(0xFFC4441A),
                    size: 24,
                  ),
                  const SizedBox(width: 10),
                  const Text(
                    'Callwave',
                    style: TextStyle(
                      color: Color(0xFF191919),
                      fontSize: 20,
                      fontWeight: FontWeight.bold,
                      letterSpacing: 0.2,
                    ),
                  ),
                  const SizedBox(width: 10),
                  Container(
                    padding: const EdgeInsets.symmetric(
                      horizontal: 9,
                      vertical: 4,
                    ),
                    decoration: BoxDecoration(
                      color: const Color(0xFFFDF0EB),
                      borderRadius: BorderRadius.circular(20),
                      border: Border.all(
                        color: const Color(0xFFC4441A),
                        width: 0.8,
                      ),
                    ),
                    child: const Text(
                      'SDK Demo',
                      style: TextStyle(
                        color: Color(0xFFC4441A),
                        fontSize: 11,
                        fontWeight: FontWeight.w600,
                        letterSpacing: 0.8,
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
      body: SafeArea(
        child: LayoutBuilder(
          builder: (context, constraints) {
            return SingleChildScrollView(
              padding: EdgeInsets.fromLTRB(18, 18, 18, 18 + bottomInset),
              keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
              child: ConstrainedBox(
                constraints: BoxConstraints(
                  minHeight: constraints.maxHeight - 36,
                ),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  mainAxisSize: MainAxisSize.min,
                  children: <Widget>[
                    // ── QUICK START BANNER ───────────────────────────────
                    const _QuickStartBanner(),
                    const SizedBox(height: 16),

                    // Pending startup action card (shown above all sections)
                    if (_pendingStartupAction != null) ...<Widget>[
                      _PendingStartupActionCard(
                        action: _pendingStartupAction!,
                        callbackCallType: _callbackCallType,
                        callbackSessionMode: _callbackSessionMode,
                        actionInFlight: _isCallActionInFlight,
                        onDismiss: _dismissPendingStartupAction,
                        onCallTypeChanged: (callType) {
                          setState(() {
                            _callbackCallType = callType;
                          });
                        },
                        onSessionModeChanged: (sessionMode) {
                          setState(() {
                            _callbackSessionMode = sessionMode;
                          });
                        },
                        onStartCallback: _startCallbackFromPendingAction,
                      ),
                      const SizedBox(height: 16),
                    ],

                    // ── CALL CONFIGURATION ───────────────────────────────
                    _SectionCard(
                      icon: Icons.tune_rounded,
                      title: 'CALL CONFIGURATION',
                      subtitle:
                          'Set the call ID used by all simulate actions below.',
                      accentColor: const Color(0xFFC4441A),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.stretch,
                        children: <Widget>[
                          TextField(
                            controller: _callIdController,
                            style: const TextStyle(
                              color: Color(0xFF191919),
                              fontFamily: 'monospace',
                              fontSize: 15,
                            ),
                            decoration: const InputDecoration(
                              labelText: 'Call ID',
                              hintText: 'demo-call-001',
                              helperText:
                                  'Must be unique per call session. Reuse the same ID to control an active call.',
                              prefixIcon: Icon(
                                Icons.tag_rounded,
                                size: 18,
                                color: Color(0xFFB5AFA7),
                              ),
                            ),
                            onChanged: (_) => setState(() {}),
                          ),
                          const SizedBox(height: 12),
                          TextField(
                            controller: _missedNotificationTextController,
                            style: const TextStyle(
                              color: Color(0xFF191919),
                              fontSize: 15,
                            ),
                            decoration: const InputDecoration(
                              labelText: 'Missed Notification Text',
                              hintText: 'You missed a call from {name}.',
                              prefixIcon: Icon(
                                Icons.notifications_rounded,
                                size: 18,
                                color: Color(0xFFB5AFA7),
                              ),
                              helperText:
                                  'Shown when a call times out. Use {name} to insert the caller\'s name.',
                            ),
                            onChanged: (_) => setState(() {}),
                          ),
                        ],
                      ),
                    ),
                    const SizedBox(height: 14),

                    // ── PERMISSIONS ──────────────────────────────────────
                    _SectionCard(
                      icon: Icons.lock_open_rounded,
                      title: 'PERMISSIONS',
                      subtitle:
                          'Request OS permissions before triggering calls. Grant these first on a fresh install.',
                      accentColor: const Color(0xFFC27803),
                      child: Row(
                        children: <Widget>[
                          Expanded(
                            child: _PermissionButton(
                              icon: Icons.notifications_rounded,
                              label: 'Notifications',
                              hint: 'Required to show incoming call alerts',
                              onPressed: callId.isEmpty
                                  ? null
                                  : _requestNotificationPermission,
                            ),
                          ),
                          const SizedBox(width: 10),
                          Expanded(
                            child: _PermissionButton(
                              icon: Icons.fullscreen_rounded,
                              label: 'Full Screen',
                              hint:
                                  'Android only — shows call over lock screen',
                              onPressed: callId.isEmpty
                                  ? null
                                  : _requestFullScreenPermission,
                            ),
                          ),
                        ],
                      ),
                    ),
                    const SizedBox(height: 14),

                    // ── INCOMING FLOW MODE ───────────────────────────────
                    _SectionCard(
                      icon: Icons.swap_horiz_rounded,
                      title: 'INCOMING FLOW MODE',
                      subtitle:
                          'Choose how the plugin handles accept/decline from the native call UI. In this example, Validated Allow and Validated Reject are mainly for terminated/cold-start testing.',
                      accentColor: const Color(0xFFC4441A),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.stretch,
                        children: <Widget>[
                          Wrap(
                            spacing: 8,
                            runSpacing: 8,
                            children: IncomingDemoMode.values.map((mode) {
                              final isSelected = mode == _incomingDemoMode;
                              return _ModeChip(
                                label: mode.label,
                                selected: isSelected,
                                locked: incomingModeLocked,
                                onSelected: () {
                                  if (isSelected) return;
                                  setState(() {
                                    _incomingDemoMode = mode;
                                  });
                                  unawaited(persistIncomingDemoMode(mode));
                                  _applyIncomingMode();
                                },
                              );
                            }).toList(growable: false),
                          ),
                          const SizedBox(height: 10),
                          Container(
                            padding: const EdgeInsets.all(12),
                            decoration: BoxDecoration(
                              color: const Color(0xFFFDF0EB),
                              borderRadius: BorderRadius.circular(8),
                              border: Border.all(
                                color: const Color(0xFFE8C8B8),
                              ),
                            ),
                            child: Row(
                              crossAxisAlignment: CrossAxisAlignment.start,
                              children: <Widget>[
                                const Icon(
                                  Icons.info_outline_rounded,
                                  size: 15,
                                  color: Color(0xFFC4441A),
                                ),
                                const SizedBox(width: 8),
                                Expanded(
                                  child: Text(
                                    _incomingModeDescription,
                                    style: const TextStyle(
                                      color: Color(0xFF3D3731),
                                      fontSize: 13,
                                      height: 1.5,
                                    ),
                                  ),
                                ),
                              ],
                            ),
                          ),
                          if (incomingModeLocked) ...<Widget>[
                            const SizedBox(height: 8),
                            const Row(
                              children: <Widget>[
                                Icon(
                                  Icons.lock_rounded,
                                  size: 13,
                                  color: Color(0xFFB5AFA7),
                                ),
                                SizedBox(width: 5),
                                Text(
                                  'Mode locked while a call is active.',
                                  style: TextStyle(
                                    color: Color(0xFFB5AFA7),
                                    fontSize: 13,
                                  ),
                                ),
                              ],
                            ),
                          ],
                        ],
                      ),
                    ),
                    const SizedBox(height: 14),

                    // ── SIMULATE CALL ────────────────────────────────────
                    _SectionCard(
                      icon: Icons.phone_rounded,
                      title: 'SIMULATE CALL',
                      subtitle:
                          'Trigger native call UI flows. Buttons are disabled when Call ID is empty.',
                      accentColor: const Color(0xFF2D8B4E),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.stretch,
                        children: <Widget>[
                          // Incoming
                          const _SubSectionLabel(
                            text: 'INCOMING',
                            color: Color(0xFF2D8B4E),
                            hint: 'Simulates a call arriving on this device',
                          ),
                          const SizedBox(height: 8),
                          Row(
                            children: <Widget>[
                              Expanded(
                                child: _CallButton(
                                  key: const ValueKey(
                                      CallDemoButtonKeys.incomingAudio),
                                  icon: Icons.call_received_rounded,
                                  label: 'Audio',
                                  color: const Color(0xFF2D8B4E),
                                  onPressed:
                                      callId.isEmpty || _isCallActionInFlight
                                          ? null
                                          : () => _showCall(
                                                callId: callId,
                                                isIncoming: true,
                                                callType: CallType.audio,
                                              ),
                                ),
                              ),
                              const SizedBox(width: 10),
                              Expanded(
                                child: _CallButton(
                                  key: const ValueKey(
                                      CallDemoButtonKeys.incomingVideo),
                                  icon: Icons.video_call_rounded,
                                  label: 'Video',
                                  color: const Color(0xFF2D8B4E),
                                  onPressed:
                                      callId.isEmpty || _isCallActionInFlight
                                          ? null
                                          : () => _showCall(
                                                callId: callId,
                                                isIncoming: true,
                                                callType: CallType.video,
                                              ),
                                ),
                              ),
                            ],
                          ),
                          const SizedBox(height: 16),

                          // Outgoing
                          const _SubSectionLabel(
                            text: 'OUTGOING',
                            color: Color(0xFF2B6CB0),
                            hint: 'Opens the outgoing call screen immediately',
                          ),
                          const SizedBox(height: 8),
                          Row(
                            children: <Widget>[
                              Expanded(
                                child: _CallButton(
                                  key: const ValueKey(
                                      CallDemoButtonKeys.outgoingAudio),
                                  icon: Icons.call_made_rounded,
                                  label: 'Audio',
                                  color: const Color(0xFF2B6CB0),
                                  onPressed:
                                      callId.isEmpty || _isCallActionInFlight
                                          ? null
                                          : () => _showCall(
                                                callId: callId,
                                                isIncoming: false,
                                                callType: CallType.audio,
                                              ),
                                ),
                              ),
                              const SizedBox(width: 10),
                              Expanded(
                                child: _CallButton(
                                  key: const ValueKey(
                                      CallDemoButtonKeys.outgoingVideo),
                                  icon: Icons.videocam_rounded,
                                  label: 'Video',
                                  color: const Color(0xFF2B6CB0),
                                  onPressed:
                                      callId.isEmpty || _isCallActionInFlight
                                          ? null
                                          : () => _showCall(
                                                callId: callId,
                                                isIncoming: false,
                                                callType: CallType.video,
                                              ),
                                ),
                              ),
                            ],
                          ),
                          const SizedBox(height: 16),

                          // Call Control
                          const _SubSectionLabel(
                            text: 'CALL CONTROL',
                            color: Color(0xFFC53030),
                            hint: 'Control an active call by its ID',
                          ),
                          const SizedBox(height: 8),
                          Row(
                            children: <Widget>[
                              Expanded(
                                child: _CallButton(
                                  icon: Icons.call_end_rounded,
                                  label: 'End Call',
                                  color: const Color(0xFFC53030),
                                  onPressed: callId.isEmpty
                                      ? null
                                      : () => _endCall(callId),
                                ),
                              ),
                              const SizedBox(width: 10),
                              Expanded(
                                child: _CallButton(
                                  icon: Icons.phone_missed_rounded,
                                  label: 'Mark Missed',
                                  color: const Color(0xFFC53030),
                                  onPressed: callId.isEmpty
                                      ? null
                                      : () => _markMissed(callId),
                                ),
                              ),
                            ],
                          ),
                          const SizedBox(height: 16),

                          // Conference
                          const _SubSectionLabel(
                            text: 'CONFERENCE',
                            color: Color(0xFF6B46C1),
                            hint:
                                'Preview multi-party call layout with mock participants',
                          ),
                          const SizedBox(height: 8),
                          Row(
                            children: <Widget>[
                              Expanded(
                                child: _CallButton(
                                  key: const ValueKey(
                                      CallDemoButtonKeys.conferenceAudio),
                                  icon: Icons.groups_rounded,
                                  label: 'Audio',
                                  color: const Color(0xFF6B46C1),
                                  onPressed: callId.isEmpty
                                      ? null
                                      : () => _openConferencePreview(
                                            callId,
                                            CallType.audio,
                                          ),
                                ),
                              ),
                              const SizedBox(width: 10),
                              Expanded(
                                child: _CallButton(
                                  key: const ValueKey(
                                      CallDemoButtonKeys.conferenceVideo),
                                  icon: Icons.duo_rounded,
                                  label: 'Video',
                                  color: const Color(0xFF6B46C1),
                                  onPressed: callId.isEmpty
                                      ? null
                                      : () => _openConferencePreview(
                                            callId,
                                            CallType.video,
                                          ),
                                ),
                              ),
                              const SizedBox(width: 10),
                              Expanded(
                                child: _CallButton(
                                  key: const ValueKey(
                                      CallDemoButtonKeys.cycleSpeaker),
                                  icon: Icons.sync_rounded,
                                  label: 'Cycle\nSpeaker',
                                  color: const Color(0xFF6B46C1),
                                  onPressed: _previewCallId == null
                                      ? null
                                      : _cycleConferenceSpeaker,
                                ),
                              ),
                            ],
                          ),
                        ],
                      ),
                    ),
                    const SizedBox(height: 14),

                    // ── EVENT LOG ────────────────────────────────────────
                    _SectionCard(
                      icon: Icons.receipt_long_rounded,
                      title: 'EVENT LOG',
                      subtitle:
                          'Real-time stream of CallEvent callbacks from the plugin.',
                      accentColor: const Color(0xFF7A7267),
                      trailing: TextButton(
                        onPressed: _eventLog.isEmpty
                            ? null
                            : () => setState(() => _eventLog.clear()),
                        style: TextButton.styleFrom(
                          foregroundColor: const Color(0xFFB5AFA7),
                          padding: EdgeInsets.zero,
                          minimumSize: const Size(48, 30),
                          tapTargetSize: MaterialTapTargetSize.shrinkWrap,
                        ),
                        child: const Text(
                          'Clear',
                          style: TextStyle(fontSize: 13),
                        ),
                      ),
                      child: Container(
                        height: 230,
                        decoration: BoxDecoration(
                          color: const Color(0xFFF5F2EE),
                          borderRadius: BorderRadius.circular(8),
                          border: Border.all(color: const Color(0xFFE8E3DC)),
                        ),
                        child: _eventLog.isEmpty
                            ? const Center(
                                child: Column(
                                  mainAxisSize: MainAxisSize.min,
                                  children: <Widget>[
                                    Icon(
                                      Icons.inbox_rounded,
                                      size: 34,
                                      color: Color(0xFFD9D3CB),
                                    ),
                                    SizedBox(height: 8),
                                    Text(
                                      'No events yet',
                                      style: TextStyle(
                                        color: Color(0xFFB5AFA7),
                                        fontSize: 15,
                                        fontWeight: FontWeight.w500,
                                      ),
                                    ),
                                    SizedBox(height: 3),
                                    Text(
                                      'Simulate a call above to see events here',
                                      style: TextStyle(
                                        color: Color(0xFFD9D3CB),
                                        fontSize: 13,
                                      ),
                                    ),
                                  ],
                                ),
                              )
                            : ListView.builder(
                                padding: const EdgeInsets.symmetric(
                                  vertical: 8,
                                ),
                                itemCount: _eventLog.length,
                                itemBuilder: (context, index) {
                                  final raw = _eventLog[index];
                                  final parts = raw.split(' ');
                                  final timestamp =
                                      parts.isNotEmpty ? parts[0] : raw;
                                  final rest = parts.length > 1
                                      ? parts.sublist(1).join(' ')
                                      : '';
                                  return Padding(
                                    padding: const EdgeInsets.symmetric(
                                      horizontal: 12,
                                      vertical: 3,
                                    ),
                                    child: Row(
                                      crossAxisAlignment:
                                          CrossAxisAlignment.start,
                                      children: <Widget>[
                                        const Text(
                                          '›',
                                          style: TextStyle(
                                            color: Color(0xFFC4441A),
                                            fontFamily: 'monospace',
                                            fontSize: 15,
                                            fontWeight: FontWeight.bold,
                                          ),
                                        ),
                                        const SizedBox(width: 6),
                                        Expanded(
                                          child: Text.rich(
                                            TextSpan(
                                              children: <InlineSpan>[
                                                TextSpan(
                                                  text: timestamp,
                                                  style: const TextStyle(
                                                    color: Color(0xFFB5AFA7),
                                                    fontFamily: 'monospace',
                                                    fontSize: 12,
                                                  ),
                                                ),
                                                if (rest.isNotEmpty)
                                                  TextSpan(
                                                    text: '  $rest',
                                                    style: const TextStyle(
                                                      color: Color(0xFF191919),
                                                      fontFamily: 'monospace',
                                                      fontSize: 13,
                                                    ),
                                                  ),
                                              ],
                                            ),
                                          ),
                                        ),
                                      ],
                                    ),
                                  );
                                },
                              ),
                      ),
                    ),
                  ],
                ),
              ),
            );
          },
        ),
      ),
    );
  }

  Future<void> _requestNotificationPermission() async {
    final granted =
        await CallwaveFlutter.instance.requestNotificationPermission();
    _pushLog('Notification permission granted: $granted');
  }

  Future<void> _requestFullScreenPermission() async {
    await CallwaveFlutter.instance.requestFullScreenIntentPermission();
    _pushLog('Requested full-screen intent permission screen.');
  }

  Future<void> _showCall({
    required String callId,
    required bool isIncoming,
    required CallType callType,
  }) async {
    if (_isCallActionInFlight) {
      return;
    }
    setState(() {
      _isCallActionInFlight = true;
    });

    final callData = isIncoming
        ? _buildCallData(
            callId: callId,
            callerName: _incomingCallerName,
            handle: _incomingHandle,
            callType: callType,
          )
        : _buildCallData(
            callId: callId,
            callerName: _outgoingCallerName,
            handle: _outgoingHandle,
            callType: callType,
          );

    try {
      if (isIncoming) {
        await CallwaveFlutter.instance.showIncomingCall(callData);
      } else {
        await CallwaveFlutter.instance.showOutgoingCall(callData);
      }
    } finally {
      if (mounted) {
        setState(() {
          _isCallActionInFlight = false;
        });
      }
    }
  }

  CallData _buildCallData({
    required String callId,
    required String callerName,
    required String handle,
    required CallType callType,
    Duration timeout = const Duration(seconds: 30),
    String? avatarUrl,
    Map<String, dynamic>? extraOverrides,
  }) {
    final customMissedNotificationText =
        _resolvedMissedNotificationText(callerName);
    final extra = <String, dynamic>{
      ...?extraOverrides,
      'callerName': callerName,
      'handle': handle,
      'callType': callType.name,
      CallDataExtraKeys.androidMissedCallNotificationText:
          customMissedNotificationText,
    };
    return CallData(
      callId: callId,
      callerName: callerName,
      handle: handle,
      avatarUrl: avatarUrl,
      timeout: timeout,
      callType: callType,
      extra: extra,
    );
  }

  String _resolvedMissedNotificationText(String callerName) {
    final customText = _missedNotificationTextController.text.trim();
    if (customText.isNotEmpty) {
      return customText.replaceAll('{name}', callerName);
    }
    return 'You missed a call from $callerName.';
  }

  Future<void> _endCall(String callId) async {
    await CallwaveFlutter.instance.endCall(callId);
  }

  Future<void> _markMissed(String callId) async {
    await CallwaveFlutter.instance.markMissed(callId);
  }

  void _applyIncomingMode() {
    configureExampleCallwave(
      engine: _engine,
      incomingDemoMode: _incomingDemoMode,
      foregroundValidator: _foregroundIncomingValidator,
    );
  }

  Future<CallAcceptDecision> _foregroundIncomingValidator(
    CallSession session,
  ) {
    return _decisionForMode(
      mode: _incomingDemoMode,
      callId: session.callId,
    );
  }

  String get _incomingModeDescription {
    switch (_incomingDemoMode) {
      case IncomingDemoMode.realtime:
        return 'Native accept opens the call flow immediately, like WhatsApp-style realtime signaling.';
      case IncomingDemoMode.validatedAllow:
        return 'Terminated/cold-start demo: native accept waits for backend validation, then opens the call only after approval. If the app is already alive, validation still runs through the live Flutter flow.';
      case IncomingDemoMode.validatedReject:
        return 'Terminated/cold-start demo: native accept waits for validation and then resolves into missed-call handling without foreground fallback. If the app is already alive, validation still runs through the live Flutter/system flow.';
      case IncomingDemoMode.declineReported:
        return 'Native decline reports to the backend in a headless Flutter isolate and dismisses the call without opening the app.';
      case IncomingDemoMode.declineFailed:
        return 'Native decline simulates a failed backend report, so the plugin falls back to missed-call UX.';
    }
  }

  void _openConferencePreview(String callIdSeed, CallType callType) {
    final callId =
        '$callIdSeed-conference-${DateTime.now().millisecondsSinceEpoch}';
    final session = CallwaveFlutter.instance.createSession(
      callData: _buildCallData(
        callId: callId,
        callerName: 'Conference',
        handle: 'group room',
        callType: callType,
        timeout: const Duration(seconds: 45),
        extraOverrides: const <String, dynamic>{
          _ExampleExtraKeys.roomType: _ExampleExtraKeys.roomTypeConference,
        },
      ),
      isOutgoing: true,
      initialState: CallSessionState.connected,
    );

    _previewCallId = callId;
    _speakerCursor = 0;
    session.updateConferenceState(
      _buildPreviewConferenceState(
        updatedAtMs: 1,
        callType: callType,
      ),
    );
    _pushLog('Conference ${callType.name} preview started for $callId');
    setState(() {});
  }

  void _cycleConferenceSpeaker() {
    final callId = _previewCallId;
    if (callId == null) {
      return;
    }
    final session = CallwaveFlutter.instance.getSession(callId);
    if (session == null || session.isEnded) {
      _pushLog('Conference preview session is not active.');
      return;
    }
    _speakerCursor += 1;
    final updatedAtMs = DateTime.now().millisecondsSinceEpoch;
    final callType = session.callData.callType;
    session.updateConferenceState(
      _buildPreviewConferenceState(
        updatedAtMs: updatedAtMs,
        callType: callType,
      ),
    );
    _pushLog('Conference speaker changed.');
  }

  ConferenceState _buildPreviewConferenceState({
    required int updatedAtMs,
    required CallType callType,
  }) {
    final participants = <CallParticipant>[
      CallParticipant(
        participantId: 'speaker-1',
        displayName: 'Ava',
        isVideoOn: callType == CallType.video,
        sortOrder: 1,
      ),
      CallParticipant(
        participantId: 'speaker-2',
        displayName: 'Milo',
        isVideoOn: callType == CallType.video,
        sortOrder: 2,
      ),
      CallParticipant(
        participantId: 'speaker-3',
        displayName: 'Nora',
        isVideoOn: callType == CallType.video,
        sortOrder: 3,
      ),
      CallParticipant(
        participantId: 'local-you',
        displayName: 'You',
        isLocal: true,
        isVideoOn: callType == CallType.video,
        sortOrder: 4,
      ),
    ];
    final activeSpeaker = participants[_speakerCursor % 3].participantId;
    return ConferenceState(
      participants: participants,
      activeSpeakerId: activeSpeaker,
      updatedAtMs: updatedAtMs,
    );
  }

  void _pushLog(String value) {
    if (!mounted) {
      return;
    }
    setState(() {
      _eventLog.insert(0, '${DateTime.now().toIso8601String()} $value');
    });
  }

  void _dismissPendingStartupAction() {
    if (_pendingStartupAction == null) {
      return;
    }
    setState(() {
      _pendingStartupAction = null;
    });
  }

  void _applyPendingStartupAction(
    CallStartupAction? action, {
    bool logEvent = true,
  }) {
    if (action == null || !mounted) {
      return;
    }
    final isConference = action.extra?[_ExampleExtraKeys.roomType] ==
        _ExampleExtraKeys.roomTypeConference;
    setState(() {
      _pendingStartupAction = action;
      _callbackCallType = action.callType;
      _callbackSessionMode = isConference
          ? CallbackSessionMode.conference
          : CallbackSessionMode.oneToOne;
      _callIdController.text = action.callId;
    });
    if (logEvent) {
      _pushLog(
        action.type == CallStartupActionType.callback
            ? 'Callback requested for ${action.callerName}.'
            : 'Opened missed call from ${action.callerName}.',
      );
    }
  }

  CallStartupAction _startupActionFromEvent({
    required CallEvent event,
    required CallStartupActionType type,
  }) {
    return CallStartupAction(
      type: type,
      callId: event.callId,
      callerName: (event.extra?['callerName'] as String?) ?? 'Unknown',
      handle: (event.extra?['handle'] as String?) ?? '',
      avatarUrl: event.extra?['avatarUrl'] as String?,
      callType: _callTypeFromExtra(event.extra?['callType']),
      extra: event.extra,
    );
  }

  CallType _callTypeFromExtra(Object? raw) {
    return raw == CallType.video.name ? CallType.video : CallType.audio;
  }

  Future<void> _startCallbackFromPendingAction() async {
    final action = _pendingStartupAction;
    if (action == null || _isCallActionInFlight) {
      return;
    }
    setState(() {
      _isCallActionInFlight = true;
    });

    final callbackCallId =
        '${action.callId}-callback-${DateTime.now().millisecondsSinceEpoch}';
    final roomType = _callbackSessionMode == CallbackSessionMode.conference
        ? _ExampleExtraKeys.roomTypeConference
        : _ExampleExtraKeys.roomTypeOneToOne;
    final callData = _buildCallData(
      callId: callbackCallId,
      callerName: action.callerName,
      handle: action.handle,
      avatarUrl: action.avatarUrl,
      callType: _callbackCallType,
      extraOverrides: <String, dynamic>{
        ...?action.extra,
        _ExampleExtraKeys.roomType: roomType,
      },
    );

    try {
      final session = CallwaveFlutter.instance.createSession(
        callData: callData,
        isOutgoing: true,
        initialState: CallSessionState.connecting,
      );
      if (_callbackSessionMode == CallbackSessionMode.conference) {
        _previewCallId = callbackCallId;
        _speakerCursor = 0;
        session.updateConferenceState(
          _buildPreviewConferenceState(
            updatedAtMs: DateTime.now().millisecondsSinceEpoch,
            callType: _callbackCallType,
          ),
        );
      } else {
        _previewCallId = null;
      }
      await CallwaveFlutter.instance.showOutgoingCall(callData);
      _pushLog(
        'Started callback ${_callbackCallType.name} for ${action.callerName} '
        '(${_callbackSessionMode.label.toLowerCase()}).',
      );
      if (mounted) {
        setState(() {
          _pendingStartupAction = null;
        });
      }
    } finally {
      if (mounted) {
        setState(() {
          _isCallActionInFlight = false;
        });
      }
    }
  }
}

class _PendingStartupActionCard extends StatelessWidget {
  const _PendingStartupActionCard({
    required this.action,
    required this.callbackCallType,
    required this.callbackSessionMode,
    required this.actionInFlight,
    required this.onDismiss,
    required this.onCallTypeChanged,
    required this.onSessionModeChanged,
    required this.onStartCallback,
  });

  final CallStartupAction action;
  final CallType callbackCallType;
  final CallbackSessionMode callbackSessionMode;
  final bool actionInFlight;
  final VoidCallback onDismiss;
  final ValueChanged<CallType> onCallTypeChanged;
  final ValueChanged<CallbackSessionMode> onSessionModeChanged;
  final Future<void> Function() onStartCallback;

  @override
  Widget build(BuildContext context) {
    final isCallback = action.type == CallStartupActionType.callback;
    return Container(
      key: const ValueKey<String>('pending-startup-action-card'),
      decoration: BoxDecoration(
        color: const Color(0xFFFFFDFB),
        borderRadius: BorderRadius.circular(14),
        border: Border.all(color: const Color(0xFFEECDB8)),
        boxShadow: const <BoxShadow>[
          BoxShadow(
            color: Color(0x18C4441A),
            blurRadius: 14,
            offset: Offset(0, 4),
          ),
        ],
      ),
      clipBehavior: Clip.antiAlias,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          // Terracotta accent top bar
          Container(height: 4, color: const Color(0xFFC4441A)),
          Padding(
            padding: const EdgeInsets.all(18),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: <Widget>[
                // Header row
                Row(
                  children: <Widget>[
                    Icon(
                      isCallback
                          ? Icons.phone_callback_rounded
                          : Icons.phone_missed_rounded,
                      color: const Color(0xFFC4441A),
                      size: 20,
                    ),
                    const SizedBox(width: 8),
                    Text(
                      isCallback ? 'CALLBACK REQUEST' : 'MISSED CALL',
                      style: const TextStyle(
                        color: Color(0xFFC4441A),
                        fontSize: 13,
                        fontWeight: FontWeight.w700,
                        letterSpacing: 1.2,
                      ),
                    ),
                  ],
                ),
                const SizedBox(height: 12),
                // Caller info
                Row(
                  children: <Widget>[
                    const SizedBox(width: 28),
                    Text(
                      action.callerName,
                      key: const ValueKey<String>(
                          'pending-startup-action-summary'),
                      style: const TextStyle(
                        color: Color(0xFF191919),
                        fontWeight: FontWeight.w600,
                        fontSize: 17,
                      ),
                    ),
                    if (action.handle.isNotEmpty) ...<Widget>[
                      const SizedBox(width: 8),
                      Text(
                        action.handle,
                        style: const TextStyle(
                          color: Color(0xFF7A7267),
                          fontSize: 15,
                        ),
                      ),
                    ],
                  ],
                ),
                if (!isCallback) ...<Widget>[
                  const SizedBox(height: 8),
                  Padding(
                    padding: const EdgeInsets.only(left: 28),
                    child: Text(
                      isCallback
                          ? 'Choose call type and session mode, then start the callback.'
                          : 'You have a missed call. Dismiss this card when ready.',
                      style: const TextStyle(
                        color: Color(0xFFB5AFA7),
                        fontSize: 13,
                        height: 1.4,
                      ),
                    ),
                  ),
                  const SizedBox(height: 16),
                  Align(
                    alignment: Alignment.centerRight,
                    child: TextButton(
                      onPressed: onDismiss,
                      style: TextButton.styleFrom(
                        foregroundColor: const Color(0xFF7A7267),
                      ),
                      child: const Text('Dismiss'),
                    ),
                  ),
                ] else ...<Widget>[
                  const SizedBox(height: 8),
                  const Padding(
                    padding: EdgeInsets.only(left: 28),
                    child: Text(
                      'Choose call type and session mode, then start the callback.',
                      style: TextStyle(
                        color: Color(0xFFB5AFA7),
                        fontSize: 13,
                        height: 1.4,
                      ),
                    ),
                  ),
                  const SizedBox(height: 16),
                  SegmentedButton<CallType>(
                    showSelectedIcon: false,
                    segments: const <ButtonSegment<CallType>>[
                      ButtonSegment<CallType>(
                        value: CallType.audio,
                        label: Text('Audio'),
                        icon: Icon(Icons.mic_rounded, size: 16),
                      ),
                      ButtonSegment<CallType>(
                        value: CallType.video,
                        label: Text('Video'),
                        icon: Icon(Icons.videocam_rounded, size: 16),
                      ),
                    ],
                    selected: <CallType>{callbackCallType},
                    onSelectionChanged: actionInFlight
                        ? null
                        : (selection) => onCallTypeChanged(selection.first),
                  ),
                  const SizedBox(height: 8),
                  SegmentedButton<CallbackSessionMode>(
                    showSelectedIcon: false,
                    segments: CallbackSessionMode.values
                        .map(
                          (mode) => ButtonSegment<CallbackSessionMode>(
                            value: mode,
                            label: Text(mode.label),
                          ),
                        )
                        .toList(growable: false),
                    selected: <CallbackSessionMode>{callbackSessionMode},
                    onSelectionChanged: actionInFlight
                        ? null
                        : (selection) => onSessionModeChanged(selection.first),
                  ),
                  const SizedBox(height: 16),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.end,
                    children: <Widget>[
                      TextButton(
                        onPressed: actionInFlight ? null : onDismiss,
                        style: TextButton.styleFrom(
                          foregroundColor: const Color(0xFF7A7267),
                        ),
                        child: const Text('Cancel'),
                      ),
                      const SizedBox(width: 8),
                      FilledButton.icon(
                        key: const ValueKey<String>('start-callback-button'),
                        onPressed: actionInFlight
                            ? null
                            : () => unawaited(onStartCallback()),
                        icon: const Icon(Icons.call_rounded, size: 16),
                        label: const Text('Start Callback'),
                        style: FilledButton.styleFrom(
                          backgroundColor: const Color(0xFFC4441A),
                          foregroundColor: Colors.white,
                        ),
                      ),
                    ],
                  ),
                ],
              ],
            ),
          ),
        ],
      ),
    );
  }
}

// ── Helper Widgets ──────────────────────────────────────────────────────────

class _QuickStartBanner extends StatelessWidget {
  // ignore: unused_element
  const _QuickStartBanner();

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        gradient: const LinearGradient(
          colors: <Color>[Color(0xFFFDF0EB), Color(0xFFFBF5F1)],
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
        ),
        borderRadius: BorderRadius.circular(14),
        border: Border.all(color: const Color(0xFFE8C8B8)),
      ),
      child: const Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          Row(
            children: <Widget>[
              Icon(Icons.rocket_launch_rounded,
                  size: 17, color: Color(0xFFC4441A)),
              SizedBox(width: 8),
              Text(
                'QUICK START',
                style: TextStyle(
                  color: Color(0xFFC4441A),
                  fontSize: 13,
                  fontWeight: FontWeight.w800,
                  letterSpacing: 1.2,
                ),
              ),
            ],
          ),
          SizedBox(height: 12),
          _QuickStartStep(
            number: '1',
            text:
                'Grant Permissions — tap both buttons in the Permissions section.',
          ),
          SizedBox(height: 8),
          _QuickStartStep(
            number: '2',
            text: 'Set a Call ID above (or keep the default "demo-call-001").',
          ),
          SizedBox(height: 8),
          _QuickStartStep(
            number: '3',
            text:
                'Pick an Incoming Flow Mode, then tap "Incoming Audio" to see the native call UI.',
          ),
          SizedBox(height: 8),
          _QuickStartStep(
            number: '4',
            text:
                'Accept or decline the call — watch events appear in the Event Log.',
          ),
        ],
      ),
    );
  }
}

class _QuickStartStep extends StatelessWidget {
  const _QuickStartStep({required this.number, required this.text});

  final String number;
  final String text;

  @override
  Widget build(BuildContext context) {
    return Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Container(
          width: 20,
          height: 20,
          decoration: const BoxDecoration(
            color: Color(0xFFC4441A),
            shape: BoxShape.circle,
          ),
          child: Center(
            child: Text(
              number,
              style: const TextStyle(
                color: Colors.white,
                fontSize: 11,
                fontWeight: FontWeight.w700,
              ),
            ),
          ),
        ),
        const SizedBox(width: 10),
        Expanded(
          child: Text(
            text,
            style: const TextStyle(
              color: Color(0xFF3D3731),
              fontSize: 14,
              height: 1.45,
            ),
          ),
        ),
      ],
    );
  }
}

class _SectionCard extends StatelessWidget {
  const _SectionCard({
    required this.icon,
    required this.title,
    required this.accentColor,
    required this.child,
    this.subtitle,
    this.trailing,
  });

  final IconData icon;
  final String title;
  final String? subtitle;
  final Color accentColor;
  final Widget child;
  final Widget? trailing;

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        color: const Color(0xFFFFFDFB),
        borderRadius: BorderRadius.circular(14),
        border: Border.all(color: const Color(0xFFE8E3DC)),
        boxShadow: const <BoxShadow>[
          BoxShadow(
            color: Color(0x0A000000),
            blurRadius: 10,
            offset: Offset(0, 3),
          ),
        ],
      ),
      clipBehavior: Clip.antiAlias,
      child: IntrinsicHeight(
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            // Left accent bar
            Container(width: 3.5, color: accentColor),
            Expanded(
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: <Widget>[
                    // Section header
                    Row(
                      children: <Widget>[
                        Icon(icon, color: accentColor, size: 17),
                        const SizedBox(width: 8),
                        Text(
                          title,
                          style: TextStyle(
                            color: accentColor,
                            fontSize: 13,
                            fontWeight: FontWeight.w700,
                            letterSpacing: 1.2,
                          ),
                        ),
                        if (trailing != null) ...<Widget>[
                          const Spacer(),
                          trailing!,
                        ],
                      ],
                    ),
                    if (subtitle != null) ...<Widget>[
                      const SizedBox(height: 5),
                      Text(
                        subtitle!,
                        style: const TextStyle(
                          color: Color(0xFFB5AFA7),
                          fontSize: 13,
                          height: 1.4,
                        ),
                      ),
                    ],
                    const SizedBox(height: 14),
                    child,
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// Keys for test targeting. Exported for widget_test.dart.
abstract final class CallDemoButtonKeys {
  static const String incomingAudio = 'incoming-audio';
  static const String incomingVideo = 'incoming-video';
  static const String outgoingAudio = 'outgoing-audio';
  static const String outgoingVideo = 'outgoing-video';
  static const String conferenceAudio = 'conference-audio';
  static const String conferenceVideo = 'conference-video';
  static const String cycleSpeaker = 'cycle-speaker';
}

class _CallButton extends StatelessWidget {
  const _CallButton({
    super.key,
    required this.icon,
    required this.label,
    required this.color,
    required this.onPressed,
  });

  final IconData icon;
  final String label;
  final Color color;
  final VoidCallback? onPressed;

  @override
  Widget build(BuildContext context) {
    final isEnabled = onPressed != null;
    final effectiveColor = isEnabled ? color : color.withValues(alpha: 0.3);
    return OutlinedButton(
      onPressed: onPressed,
      style: OutlinedButton.styleFrom(
        foregroundColor: effectiveColor,
        side: BorderSide(color: effectiveColor.withValues(alpha: 0.4)),
        backgroundColor:
            isEnabled ? color.withValues(alpha: 0.06) : const Color(0xFFF5F2EE),
        padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 10),
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          Icon(icon, size: 22, color: effectiveColor),
          const SizedBox(height: 5),
          Text(
            label,
            textAlign: TextAlign.center,
            style: TextStyle(
              fontSize: 13,
              fontWeight: FontWeight.w600,
              color: effectiveColor,
              height: 1.2,
            ),
          ),
        ],
      ),
    );
  }
}

class _SubSectionLabel extends StatelessWidget {
  const _SubSectionLabel({
    required this.text,
    required this.color,
    this.hint,
  });

  final String text;
  final Color color;
  final String? hint;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Row(
          children: <Widget>[
            Container(
              width: 2.5,
              height: 14,
              decoration: BoxDecoration(
                color: color,
                borderRadius: BorderRadius.circular(1),
              ),
            ),
            const SizedBox(width: 7),
            Text(
              text,
              style: TextStyle(
                color: color,
                fontSize: 12,
                fontWeight: FontWeight.w700,
                letterSpacing: 1.0,
              ),
            ),
          ],
        ),
        if (hint != null) ...<Widget>[
          const SizedBox(height: 3),
          Padding(
            padding: const EdgeInsets.only(left: 10),
            child: Text(
              hint!,
              style: const TextStyle(
                color: Color(0xFFB5AFA7),
                fontSize: 12,
                height: 1.3,
              ),
            ),
          ),
        ],
      ],
    );
  }
}

class _ModeChip extends StatelessWidget {
  const _ModeChip({
    required this.label,
    required this.selected,
    required this.locked,
    required this.onSelected,
  });

  final String label;
  final bool selected;
  final bool locked;
  final VoidCallback onSelected;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: locked ? null : onSelected,
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 150),
        padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
        decoration: BoxDecoration(
          color: selected ? const Color(0xFFC4441A) : const Color(0xFFF0ECE7),
          borderRadius: BorderRadius.circular(20),
          border: Border.all(
            color: selected ? const Color(0xFFC4441A) : const Color(0xFFD9D3CB),
          ),
        ),
        child: Text(
          label,
          style: TextStyle(
            color: selected
                ? Colors.white
                : locked
                    ? const Color(0xFFD9D3CB)
                    : const Color(0xFF3D3731),
            fontSize: 14,
            fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
          ),
        ),
      ),
    );
  }
}

class _PermissionButton extends StatelessWidget {
  const _PermissionButton({
    required this.icon,
    required this.label,
    required this.onPressed,
    this.hint,
  });

  final IconData icon;
  final String label;
  final String? hint;
  final VoidCallback? onPressed;

  @override
  Widget build(BuildContext context) {
    final isEnabled = onPressed != null;
    const color = Color(0xFFC27803);
    final effectiveColor = isEnabled ? color : color.withValues(alpha: 0.35);
    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: <Widget>[
        OutlinedButton.icon(
          onPressed: onPressed,
          icon: Icon(icon, size: 18, color: effectiveColor),
          label: Text(
            label,
            style: TextStyle(
              color: effectiveColor,
              fontSize: 14,
              fontWeight: FontWeight.w600,
            ),
          ),
          style: OutlinedButton.styleFrom(
            side: BorderSide(
              color: effectiveColor.withValues(alpha: 0.5),
            ),
            backgroundColor:
                isEnabled ? const Color(0xFFFFF8EB) : const Color(0xFFF5F2EE),
            padding: const EdgeInsets.symmetric(vertical: 12),
            shape:
                RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
          ),
        ),
        if (hint != null) ...<Widget>[
          const SizedBox(height: 5),
          Text(
            hint!,
            textAlign: TextAlign.center,
            style: const TextStyle(
              color: Color(0xFFB5AFA7),
              fontSize: 12,
              height: 1.3,
            ),
          ),
        ],
      ],
    );
  }
}
1
likes
140
points
124
downloads

Publisher

verified publishereffdel.com

Weekly Downloads

Flutter plugin for WhatsApp-style or Google Meet/Microsoft Teams-style VoIP call UX. Incoming/outgoing notifications, accept/decline/timeout, CallKit and Android full-screen intents.

Repository (GitHub)
View/report issues

Documentation

API reference

License

BSD-3-Clause (license)

Dependencies

callwave_flutter_method_channel, callwave_flutter_platform_interface, flutter

More

Packages that depend on callwave_flutter

Packages that implement callwave_flutter