import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:test_sa/border_beam/src/beam_style.dart'; import 'package:test_sa/border_beam/src/border_beam.dart'; import 'package:test_sa/controllers/providers/api/user_provider.dart'; import 'package:test_sa/extensions/context_extension.dart'; import 'package:test_sa/extensions/int_extensions.dart'; import 'package:test_sa/extensions/text_extensions.dart'; import 'package:test_sa/extensions/widget_extensions.dart'; import 'package:test_sa/new_views/app_style/app_color.dart'; import 'package:test_sa/new_views/common_widgets/default_app_bar.dart'; import 'package:test_sa/thinking_orbs/src/thinking_orb.dart'; import '../controller/copilot_provider.dart'; import '../widgets/copilot_message_bubble.dart'; /// Push-to-Talk realtime voice screen for Copilot. class CopilotPushToTalkPage extends StatefulWidget { const CopilotPushToTalkPage({Key? key}) : super(key: key); @override State createState() => _CopilotPushToTalkPageState(); } class _CopilotPushToTalkPageState extends State with WidgetsBindingObserver { final ScrollController _scrollController = ScrollController(); CopilotProvider? _provider; @override void didChangeDependencies() { super.didChangeDependencies(); _provider = context.read(); } @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { final provider = context.read(); provider.startNewPttConversation(); provider.connectPushToTalkHub().catchError((_) {}); }); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.paused) { // OS suspends sockets/mic in background; stop any in-progress turn. _provider?.cancelPushToTalk().catchError((_) {}); } else if (state == AppLifecycleState.resumed) { _provider?.ensurePushToTalkConnected().catchError((_) {}); } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _scrollController.dispose(); _provider?.cancelPushToTalk().catchError((_) {}); _provider?.disconnectPushToTalkHub().catchError((_) {}); super.dispose(); } void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 250), curve: Curves.easeOut, ); } }); } String _statusLabel(PttStatus status) { switch (status) { case PttStatus.idle: return "Hold the mic and speak"; case PttStatus.connecting: return "Connecting…"; case PttStatus.recording: return "Listening…"; case PttStatus.thinking: return "Thinking…"; case PttStatus.playingAudio: return "Speaking…"; case PttStatus.error: return "Something went wrong"; } } /// Shown in place of the mic while the socket is connecting / disconnected. Widget _buildConnectionPlaceholder(BuildContext context, CopilotProvider provider) { // Treat "disconnected without an error" as connecting (e.g. the first frame // before the connection attempt kicks off). final isConnecting = provider.pttHubState == PttHubState.connecting || provider.pttErrorMessage == null; return SizedBox( height: 148, child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ if (isConnecting) ...[ const SizedBox(height: 28, width: 28, child: CircularProgressIndicator(strokeWidth: 2.5)), 12.height, Text( "Connecting…", style: AppTextStyles.bodyText2.copyWith(color: AppColor.lightTextColor(context)), ), ] else TextButton.icon( onPressed: () => provider.ensurePushToTalkConnected().catchError((_) {}), icon: const Icon(Icons.refresh_rounded), label: const Text("Retry connection"), ), ], ), ).paddingOnly(top: 8, bottom: 8); } @override Widget build(BuildContext context) { return Scaffold( appBar: const DefaultAppBar(title: "Push to Talk"), body: Column( children: [ Expanded( child: Consumer( builder: (context, provider, _) { if (provider.pttMessages.isEmpty) { return Column( crossAxisAlignment: CrossAxisAlignment.center, spacing: 24, mainAxisSize: MainAxisSize.min, children: [ "atoms_logo".toSvgAsset(width: 54), Text( "Press and hold the mic to talk", overflow: TextOverflow.ellipsis, maxLines: 1, style: AppTextStyles.heading6.copyWith(color: AppColor.lightTextColor(context), fontWeight: FontWeight.w500), ), ], ).center; } _scrollToBottom(); final messages = provider.pttMessages; return ListView.builder( controller: _scrollController, padding: const EdgeInsets.all(16), itemCount: messages.length, itemBuilder: (context, index) { final currentMessage = messages[index]; final bool showHeader = index == 0 || currentMessage.role != messages[index - 1].role; return CopilotMessageBubble( message: currentMessage, showHeader: showHeader, userAvatar: Provider.of(context, listen: false).user?.profilePhotoName, ); }, ); }, ), ), Consumer( builder: (context, provider, _) { if (provider.pttErrorMessage == null) return const SizedBox(); return Container( width: double.infinity, color: Colors.red.withValues(alpha: 0.1), padding: const EdgeInsets.all(8), child: Text(provider.pttErrorMessage!, style: const TextStyle(color: Colors.red)), ); }, ), SafeArea( child: Consumer( builder: (context, provider, _) { // Hide the mic until the socket connection is established. if (provider.pttHubState != PttHubState.connected) { return _buildConnectionPlaceholder(context, provider); } final status = provider.pttStatus; final isRecording = status == PttStatus.recording; final isBusy = status == PttStatus.connecting || status == PttStatus.thinking || status == PttStatus.playingAudio; return Column( mainAxisSize: MainAxisSize.min, children: [ Text( _statusLabel(status), style: AppTextStyles.bodyText2.copyWith(color: AppColor.lightTextColor(context)), ), 16.height, Container( height: 100, alignment: Alignment.center, child: Container( padding: const EdgeInsets.all(0), decoration: BoxDecoration(color: AppColor.background(context), shape: BoxShape.circle, border: Border.all(color: Colors.transparent)), child: BorderBeam( size: BeamSize.pulseOutside, colorVariant: BeamColorVariant.colorful, reserveGlowSpace: false, borderRadius: 50, strength: 0.7, child: GestureDetector( onLongPressStart: isBusy ? null : (_) { context.read().beginPushToTalk(context.settingProvider.language!).catchError((_) {}); }, onLongPressEnd: (_) { context.read().endPushToTalk().catchError((_) {}); }, onLongPressCancel: () { context.read().endPushToTalk().catchError((_) {}); }, child: AnimatedContainer( duration: const Duration(milliseconds: 150), height: isRecording ? 96 : 80, width: isRecording ? 96 : 80, child: isBusy ? Padding( padding: const EdgeInsets.all(12), child: ThinkingOrb( size: 48, state: ThinkingOrbState.working, theme: context.isDark ? ThinkingOrbTheme.dark : ThinkingOrbTheme.light, ), ) : Icon(Icons.mic_rounded, color: AppColor.icon2Color(context), size: isRecording ? 44 : 36), ), ), // "atoms_logo".toSvgAsset().paddingAll(16), ), ), ), 24.height, ], ).paddingOnly(top: 8, bottom: 8); }, ), ), ], ), ); } }