Skip to content

firebase_ai liveGenerativeModel() live transcription for user voice is not accurate #18123

@Magesh-kanna

Description

@Magesh-kanna

[READ] Step 1: Are you in the right place?

Issues filed here should be about bugs in the code in this repository. If you have a general
question, need help debugging, or fall into some other category use one of these other channels:

  • For general technical questions, post a question on StackOverflow
    with the firebase tag.
  • For general Firebase discussion, use the
    firebase-talk google group.
  • For help troubleshooting your application that does not fall under one of the above categories,
    reach out to the personalized Firebase support channel.

[REQUIRED] Step 2: Describe your environment

  • Android Studio version: Anti-gravity latest version
  • Firebase Component: (firebase_ai, firebase logic)
  • Component version: firebase_ai: 3.7.0

[REQUIRED] Step 3: Describe the problem

I've implemented the firebase_ai liveGenerativeModel plugin in my flutter app and the live transcription is not accurate when the user is speaking to AI - but AI picks up the correct sentence and live transcription gives the wrong text and the UI is reflected accordingly.

Steps to reproduce:

Start the session and start talking to AI - you'll see the live transcription mismatch with the actual user voice - but AI picks the correct input.

Relevant Code:

import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';

import 'package:firebase_ai/firebase_ai.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_soloud/flutter_soloud.dart';
import 'package:flutter_svg/svg.dart';
import 'package:go_router/go_router.dart';
import 'package:lottie/lottie.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';
import 'package:record/record.dart';
import 'package:uuid/uuid.dart';
import 'package:whomie/core/constants/app_colors.dart';
import 'package:whomie/core/constants/image_path.dart';
import 'package:whomie/core/constants/internet_status.dart';
import 'package:whomie/core/database/app_database.dart';
import 'package:whomie/core/helper/entry_feedback_helper.dart';
import 'package:whomie/core/helper/logger_helper.dart';
import 'package:whomie/core/helper/text_helper.dart';
import 'package:whomie/core/helper/voice_prompt_generator.dart';
import 'package:whomie/core/helper/whomie_toast.dart';
import 'package:whomie/core/presentation/widgets/whomie_app_bar.dart';
import 'package:whomie/core/presentation/widgets/whomie_scaffold.dart';
import 'package:whomie/core/providers/internet_status_provider.dart';
import 'package:whomie/core/services/fcm_service.dart';
import 'package:whomie/core/services/feature_flagging.dart';
import 'package:whomie/core/services/profile_service.dart';
import 'package:whomie/features/auth/data/auth_repository.dart';
import 'package:whomie/features/journal/data/journal_repository.dart';
import 'package:whomie/features/journal/data/journal_service.dart';
import 'package:whomie/features/journal/presentation/providers/voice_journal_persistence_provider.dart';
import 'package:whomie/features/mood/data/mood_repository.dart';
import 'package:whomie/features/mood/presentation/providers/mood_checkin_provider.dart';

class VoiceJournalScreen extends ConsumerStatefulWidget {
  final String? sessionId;
  final double? initialMood;

  const VoiceJournalScreen({super.key, this.sessionId, this.initialMood});

  @override
  ConsumerState<VoiceJournalScreen> createState() => _VoiceJournalScreenState();
}

class _VoiceJournalScreenState extends ConsumerState<VoiceJournalScreen>
    with SingleTickerProviderStateMixin {
  // Gemini Live Model & Session
  late LiveGenerativeModel _liveModel;
  LiveSession? _session;

  // Audio recording & playback
  final _recorder = AudioRecorder();
  AudioSource? _audioSrc;
  SoundHandle? _handle;
  bool _disposed = false;

  // State Management
  bool _audioReady = false;
  bool _settingUpSession = false;
  bool _conversationActive = false;
  bool _isAiSpeaking = false;
  StreamController<bool>? _stopController;
  StreamController<InlineDataPart>? _geminiStreamController;

  late String _sessionId;
  final List<Map<String, dynamic>> _messages = [];
  final ScrollController _scrollController = ScrollController();
  late AnimationController _pulseController;
  String? _lastAdvice;

  StreamSubscription<Uint8List>? _micSubscription;

  // Solution tracking
  List<Map<String, String>> _currentSolutions = [];

  @override
  void initState() {
    super.initState();
    _sessionId = widget.sessionId ?? const Uuid().v4();

    _pulseController = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 1),
    )..repeat(reverse: true);

    WidgetsBinding.instance.addPostFrameCallback((_) async {
      if (mounted) {
        await _initializeLiveModel();
        await _initializeAudio();
        if (mounted) {
          _startConversation();
        }
      }
      if (widget.sessionId != null) {
        _loadHistory();
      }
    });

    // Trigger premium entry feel
    EntryFeedbackHelper.triggerPremiumEntry();
  }

  Future<void> _initializeLiveModel() async {
    setState(() => _settingUpSession = true);
    try {
      final moodLabel = widget.initialMood != null
          ? _getMoodLabel(widget.initialMood!)
          : 'neutral';
      final prompt = await VoicePromptGenerator.generatePrompt(
        userMessage: "",
        ref: ref,
        moodLabel: moodLabel,
      );

      // Getting the AI Model name from the remote config.
      final voiceAIModelNamefromRemoteConfig = FeatureFlags.aiVoiceModel;
      LoggerHelper.whomieLog(
        '🚀 Remote Config AI Model Name Voice: $voiceAIModelNamefromRemoteConfig',
      );

      final model = voiceAIModelNamefromRemoteConfig != ''
          ? voiceAIModelNamefromRemoteConfig
          : 'gemini-2.0-flash-live-preview-04-09';

      LoggerHelper.whomieLog('USED AI Model Name Voice: $model');

      _liveModel = FirebaseAI.vertexAI().liveGenerativeModel(
        model: model,
        systemInstruction: Content.text(prompt),
        liveGenerationConfig: LiveGenerationConfig(
          speechConfig: SpeechConfig(
            voiceName: FeatureFlags.aiVoiceName != ''
                ? FeatureFlags.aiVoiceName
                : 'zephyr',
          ),
          responseModalities: [ResponseModalities.audio],
          inputAudioTranscription: AudioTranscriptionConfig(),
          outputAudioTranscription: AudioTranscriptionConfig(),
        ),
        tools: [
          Tool.functionDeclarations([
            FunctionDeclaration(
              'provideSolutions',
              'Provides exactly 3 actionable solutions, an empathic summary, and optionally a notification reminder to the user.',
              parameters: {
                'advice': Schema.string(
                  description: 'Empathic summary and feedback for the user.',
                ),
                'solutions': Schema.array(
                  description:
                      'Exactly 3 actionable options. Each MUST have a text and a category (physical, mental, or emotional).',
                  items: Schema.object(
                    properties: {
                      'text': Schema.string(description: 'The solution text.'),
                      'category': Schema.string(
                        description:
                            'Category: "physical", "mental", or "emotional".',
                      ),
                    },
                  ),
                ),
                'notification': Schema.object(
                  description:
                      'Optional notification settings. Null if no follow-up is needed.',
                  properties: {
                    'date': Schema.string(
                      description: 'Date in YYYY-MM-DD format.',
                    ),
                    'frequency': Schema.string(
                      description: 'Frequency: "once", "daily", or "weekly".',
                    ),
                    'time': Schema.string(
                      description:
                          'Time in HH:MM AM/PM format (e.g., "09:00 AM").',
                    ),
                  },
                  nullable: true,
                  optionalProperties: ['date', 'frequency', 'time'],
                ),
              },
            ),
          ]),
        ],
      );
      LoggerHelper.whomieLog('✅ Live Model initialized');
    } catch (e) {
      LoggerHelper.whomieLog('❌ Error setting up live model: $e');
    } finally {
      if (mounted) setState(() => _settingUpSession = false);
    }
  }

  Future<void> _initializeAudio() async {
    try {
      final hasPermission = await _recorder.hasPermission();
      if (!hasPermission) {
        if (mounted) {
          WhomieToast.error(
            context: context,
            text: 'Microphone permission is required.',
          );
        }
        return;
      }

      if (!SoLoud.instance.isInitialized) {
        await SoLoud.instance.init(sampleRate: 24000, channels: Channels.mono);
      }
      if (mounted) setState(() => _audioReady = true);
    } catch (e) {
      log("Error initializing audio: $e");
    }
  }

  Future<void> _loadHistory() async {
    final repository = ref.read(journalRepositoryProvider);
    final entries = await repository.watchSessionEntries(_sessionId).first;

    if (mounted) {
      setState(() {
        for (var entry in entries) {
          if (entry.content.isNotEmpty) {
            _messages.add({
              'role': entry.isUser ? 'user' : 'ai',
              'content': entry.content,
            });
          }
        }
      });
      _scrollToBottom(delayed: true);
    }
  }

  void _scrollToBottom({bool delayed = false}) {
    // If delayed, wait for animations (like the AnimatedSwitcher 400ms duration)
    final delay = delayed ? const Duration(milliseconds: 300) : Duration.zero;

    Future.delayed(delay, () {
      if (!mounted) return;
      WidgetsBinding.instance.addPostFrameCallback((_) {
        if (_scrollController.hasClients) {
          _scrollController.animateTo(
            _scrollController.position.maxScrollExtent,
            duration: const Duration(milliseconds: 200),
            curve: Curves.easeOut,
          );
        }
      });
    });
  }

  Future<void> _toggleConversation() async {
    if (_conversationActive) {
      await _stopConversation();
    } else {
      await _startConversation();
    }
  }

  // FIXED: Start mic streaming - simplified and clean
  Future<void> _startMicStreaming() async {
    if (_micSubscription != null || _session == null) {
      log("⚠️ Mic already active or session null");
      return;
    }

    log("🎤 Initializing mic stream...");

    final recordConfig = RecordConfig(
      encoder: AudioEncoder.pcm16bits,
      sampleRate: 24000,
      numChannels: 1,
      echoCancel: true,
      noiseSuppress: true,
      androidConfig: const AndroidRecordConfig(
        audioSource: AndroidAudioSource.voiceCommunication,
      ),
      iosConfig: const IosRecordConfig(
        categoryOptions: [IosAudioCategoryOption.defaultToSpeaker],
      ),
    );

    try {
      final inputStream = await _recorder.startStream(recordConfig);
      _micSubscription = inputStream.listen((data) {
        // GATING LOGIC: Only send to Gemini if AI is NOT speaking and session is active
        if (_geminiStreamController != null &&
            !_geminiStreamController!.isClosed &&
            !_isAiSpeaking) {
          _geminiStreamController!.add(InlineDataPart('audio/pcm', data));
        }
      });

      log('✅ Mic initialized and listener started');
    } catch (e) {
      log('❌ Error initializing mic stream: $e');
      _micSubscription = null;
    }
  }

  // FIXED: Stop mic streaming - actually stops the recording
  Future<void> _stopMicStreaming() async {
    log('🛑 Stopping mic completely...');
    try {
      if (_micSubscription != null) {
        await _micSubscription!.cancel();
        _micSubscription = null;
      }
      // Check if recorder is disposed before accessing it
      if (!_disposed) {
        try {
          if (await _recorder.isRecording()) {
            await _recorder.stop();
          }
        } catch (e) {
          // Recorder may already be disposed, ignore this specific error
          log('⚠️ Recorder access skipped (might already be disposed)');
        }
      }
      log('✅ Mic stopped successfully');
    } catch (e) {
      log('⚠️ Error stopping mic streaming: $e');
    }
  }

  // FIXED: Wait for playback completion
  Future<void> _waitForPlaybackCompletion() async {
    log("🔊 Waiting for AI playback to complete...");

    if (_handle == null || _audioSrc == null) {
      log("⚠️ No active audio handle");
      _isAiSpeaking = false;
      return;
    }

    // Small delay to ensure audio has started
    await Future.delayed(const Duration(milliseconds: 150));

    // Wait for playback to finish naturally
    int checkCount = 0;
    const maxChecks = 300; // 30 second timeout

    while (mounted && _handle != null && checkCount < maxChecks) {
      final isValid = SoLoud.instance.getIsValidVoiceHandle(_handle!);

      if (!isValid) {
        log(
          "✅ Audio playback finished after $checkCount checks (~${checkCount * 100}ms)",
        );
        break;
      }

      checkCount++;
      await Future.delayed(const Duration(milliseconds: 100));
    }

    if (checkCount >= maxChecks) {
      log("⚠️ Playback timeout reached");
    }

    if (!mounted) return;

    // Clean up handle
    _handle = null;
    _isAiSpeaking = false;

    log("🎤 Playback complete, Whomie is listening again.");
  }

  Future<void> _startConversation() async {
    if (!_audioReady) return;

    setState(() {
      _settingUpSession = true;
      _currentSolutions = [];
    });

    try {
      // 1. Connect to Gemini Live
      log("🚀 Connecting to Whomie AI Voice Live...");
      _session = await _liveModel.connect();
      log("✅ Connected");

      // Initialize persistent stream for Gemini
      _geminiStreamController = StreamController<InlineDataPart>();
      _session!.sendMediaStream(_geminiStreamController!.stream);

      _stopController = StreamController<bool>();
      unawaited(_processMessagesContinuously());

      await _startMicStreaming();

      setState(() {
        _conversationActive = true;
        _settingUpSession = false;
      });
    } catch (e) {
      if (mounted) {
        setState(() => _settingUpSession = false);
        String errorMsg = 'Error starting conversation: $e';
        bool isRateLimit =
            e.toString().contains('429') ||
            e.toString().contains('Resource exhausted');

        if (isRateLimit) {
          errorMsg =
              "I am busy with lot of people trying, I'm working on it. Please wait.";
          WhomieToast.warning(
            context: context,
            text: errorMsg,
            subtitle: 'Please try saying again',
          );
          setState(() {
            _messages.add({
              'role': 'ai',
              'content': errorMsg,
              'timestamp': DateTime.now(),
            });
            _conversationActive = false;
          });
        } else {
          WhomieToast.error(
            context: context,
            text: errorMsg,
            subtitle: 'Please try saying again',
          );
        }
      }
    }
  }

  Future<void> _stopConversation({bool updateUi = true}) async {
    if (updateUi && mounted) {
      setState(() {
        _settingUpSession = true;
      });
    }

    try {
      await _stopMicStreaming();
      // await _recorder.stop();
      if (_audioSrc != null) {
        SoLoud.instance.setDataIsEnded(_audioSrc!);
      }
      if (_handle != null) {
        await SoLoud.instance.stop(_handle!);
      }

      await _session?.close();
      if (_geminiStreamController != null &&
          !_geminiStreamController!.isClosed) {
        await _geminiStreamController?.close();
        _geminiStreamController = null;
      }
      if (_stopController != null && !_stopController!.isClosed) {
        _stopController?.add(true);
        await _stopController?.close();
      }

      if (updateUi && mounted) {
        setState(() {
          _conversationActive = false;
          _isAiSpeaking = false;
          _settingUpSession = false;
        });
      }
    } catch (e) {
      log("Error stopping conversation: $e");
      if (updateUi && mounted) {
        setState(() => _settingUpSession = false);
      }
    }
  }

  Future<void> _processMessagesContinuously() async {
    if (_session == null) return;

    bool shouldContinue = true;
    _stopController!.stream.listen((stop) {
      if (stop) shouldContinue = false;
    });

    while (shouldContinue) {
      try {
        await for (final response in _session!.receive()) {
          log("Gemini Server Response type: ${response.runtimeType}");
          // response is usually LiveServerResponse which has a 'message' field
          try {
            final dynamic resp = response;
            if (resp.message is LiveServerMessage) {
              await _handleLiveServerMessage(resp.message as LiveServerMessage);
            } else if (resp is LiveServerMessage) {
              await _handleLiveServerMessage(resp);
            }
          } catch (e) {
            log("Error parsing response: $e");
          }
        }
      } catch (e) {
        if (e.toString().contains('closed') || e.toString().contains('1006')) {
          log("⚠️ Connection lost in _processMessagesContinuously: $e");
          if (mounted && _conversationActive) {
            WhomieToast.error(
              context: context,
              text: 'Connection lost',
              subtitle: 'Please restart the session',
            );
            _stopConversation();
          }
          break;
        }
        LoggerHelper.whomieLog("Error in message processing: $e");
        await Future.delayed(const Duration(seconds: 1));
      }
    }
  }

  Future<void> _handleLiveServerMessage(LiveServerMessage message) async {
    try {
      log("Handling Server Message type: ${message.runtimeType}");
      if (message is LiveServerContent) {
        if (message.modelTurn != null) {
          log("📦 Got LiveServerContent with modelTurn");
        }
        if (message.turnComplete == true) {
          log("🔚 Turn Complete");
        }
        if (message.interrupted == true) {
          log("⚠️ Interrupted");
        }
        final parts = message.modelTurn?.parts;
        if (parts != null) {
          if (!mounted) return;

          if (!_isAiSpeaking) {
            setState(() => _isAiSpeaking = true);
            // Create a fresh audio source for this turn
            _audioSrc = SoLoud.instance.setBufferStream(
              bufferingType: BufferingType.released,
              bufferingTimeNeeds: 0,
            );
            _handle = await SoLoud.instance.play(_audioSrc!);
          }

          for (final part in parts) {
            if (part is TextPart) {
              _updateTranscription(part.text, isUser: false);
            } else if (part is InlineDataPart) {
              if (part.mimeType.startsWith('audio') && _audioSrc != null) {
                SoLoud.instance.addAudioDataStream(_audioSrc!, part.bytes);
              }
            }
          }
        }

        // Handle transcriptions in firebase_ai 3.x
        if (message.outputTranscription != null) {
          final text = message.outputTranscription!.text;
          if (text != null && text.isNotEmpty) {
            log("📝 AI Transcript: $text");
            _updateTranscription(text, isUser: false);
          }
        }
        if (message.inputTranscription != null) {
          final text = message.inputTranscription!.text;
          if (text != null && text.isNotEmpty) {
            log("📝 User Transcript: $text");
            _updateTranscription(text, isUser: true);
          }
        }

        if (message.turnComplete == true) {
          log("🔚 AI finished speaking");

          if (mounted) {
            setState(() {
              // Removed _lastMessageId reset
            });
          }

          if (_audioSrc != null) {
            SoLoud.instance.setDataIsEnded(_audioSrc!);
          }
          _waitForPlaybackCompletion(); // 🎤 USER CAN TALK AGAIN
        }
      } else if (message is LiveServerToolCall) {
        final toolCalls = message.functionCalls;
        if (toolCalls != null) {
          for (final fc in toolCalls) {
            if (fc.name == 'provideSolutions') {
              final advice = fc.args['advice'] as String?;
              final rawSolutions = (fc.args['solutions'] as List<dynamic>?);
              final List<Map<String, String>> solutions = [];
              if (rawSolutions != null) {
                for (var s in rawSolutions) {
                  if (s is Map) {
                    solutions.add({
                      'text': s['text']?.toString() ?? '',
                      'category': s['category']?.toString() ?? 'mental',
                    });
                  } else {
                    solutions.add({'text': s.toString(), 'category': 'mental'});
                  }
                }
              }

              if (mounted && advice != null) {
                setState(() {
                  _currentSolutions = solutions;
                  _messages.add({
                    'role': 'ai',
                    'content': advice,
                    'timestamp': DateTime.now(),
                  });
                });
                _scrollToBottom(delayed: true);
                _lastAdvice = advice;
                // Update provider state
                ref
                    .read(voiceJournalPersistenceProvider.notifier)
                    .updateState(advice: advice, currentSolutions: solutions);

                Future.delayed(const Duration(seconds: 2), () {
                  _toggleConversation();
                });

                _session?.send(
                  input: Content.functionResponse(fc.name, {
                    'status': 'displayed',
                  }),
                );
              }
            }
          }
        }
      } else {
        log("🔍 Other Server Message: $message");
      }
    } catch (e) {
      LoggerHelper.whomieLog("Error handling server message: $e");
    }
  }

  void _updateTranscription(String text, {required bool isUser}) {
    if (!mounted) return;

    // Clean text if it's from AI
    final cleanedText = isUser ? text : _cleanAiResponse(text);
    if (cleanedText.isEmpty) return;

    // Strict English Filter: Reject text with non-Latin characters if it's from the user or misdetected AI
    if (!_isEnglishText(cleanedText)) {
      LoggerHelper.whomieLog(
        "🚫 Filtered out non-English content: $cleanedText",
      );
      return;
    }

    setState(() {
      final String targetRole = isUser ? 'user' : 'ai';

      bool isNewSpeaker =
          _messages.isEmpty || _messages.last['role'] != targetRole;

      if (isNewSpeaker) {
        _messages.add({
          'role': targetRole,
          'content': cleanedText,
          'timestamp': DateTime.now(),
        });
      } else {
        _messages.last['content'] =
            (_messages.last['content'] ?? '') + cleanedText;
      }
    });

    _scrollToBottom(delayed: true);
  }

  String _cleanAiResponse(String text) {
    if (text.isEmpty) return text;

    // Use a local copy to avoid modifying the original if we decide not to clean
    String result = text;

    // 1. Remove markdown code blocks if present
    if (result.contains('```')) {
      final regExp = RegExp(r'```(?:json)?\s*([\s\S]*?)\s*```');
      final match = regExp.firstMatch(result);
      if (match != null) {
        result = match.group(1)?.trim() ?? result;
      }
    }

    // 2. Try to parse as JSON if it looks like it
    if (result.trim().startsWith('{') && result.trim().endsWith('}')) {
      try {
        final json = jsonDecode(result.trim());
        if (json is Map<String, dynamic>) {
          if (json.containsKey('question')) {
            return json['question'].toString();
          } else if (json.containsKey('advice')) {
            return json['advice'].toString();
          }
        }
      } catch (_) {
        // Not valid JSON, continue
      }
    }

    // 3. Last resort: simple regex cleanup for "question": "..."
    final questionMatch = RegExp(
      r'"question"\s*:\s*"([^"]*)"',
    ).firstMatch(result);
    if (questionMatch != null) {
      return questionMatch.group(1) ?? result;
    }

    return result; // Return raw text (preserving spaces) if no cleaning matched
  }

  bool _isEnglishText(String text) {
    if (text.isEmpty) return true;
    // This regex allows Latin characters, numbers, common punctuation, and emojis.
    // It rejects scripts like Telugu, Hindi, Japanese, etc.
    final latinRegex = RegExp(
      r'^[a-zA-Z0-9\s\p{P}\p{S}\p{Emoji}]*$',
      unicode: true,
    );
    return latinRegex.hasMatch(text);
  }

  Future<void> _onSolutionSelected(String solution) async {
    final repository = ref.read(journalRepositoryProvider);

    // Find the category associated with the selected solution
    String? selectedCategory;
    for (final sol in _currentSolutions) {
      if (sol['text'] == solution) {
        selectedCategory = sol['category'];
        break;
      }
    }

    await repository.saveActionSelection(
      sessionId: _sessionId,
      optionA: _currentSolutions.isNotEmpty
          ? _currentSolutions[0]['text'] ?? ''
          : '',
      optionB: _currentSolutions.length > 1
          ? _currentSolutions[1]['text'] ?? ''
          : '',
      optionC: _currentSolutions.length > 2
          ? _currentSolutions[2]['text'] ?? ''
          : '',
      selectedOption: solution,
      category: selectedCategory,
    );

    log("✅ Voice AI Options Selected: $solution");
    log("Current Session ID: $_sessionId");
    log("Current Solutions: $_currentSolutions");
    log("Current Selected Solution: $solution");

    // Generate and save conversation title for voice chat session
    ref
        .read(journalServiceProvider)
        .generateAndSaveSessionTitle(_sessionId, _messages);

    // completing the voice chat session
    await repository.markJournalAsCompleted(_sessionId);

    // Saving the mood in mood table
    if (widget.initialMood != null) {
      final moodRepo = ref.read(moodRepositoryProvider);
      await moodRepo.saveMood(
        widget.initialMood!.round(),
        moodName: _getMoodLabel(widget.initialMood!),
        note: 'Saved after live voice session',
      );
      ref.read(moodCheckinStateProvider.notifier).setSubmitted();
    }

    // Showing the notification for the selected task
    await ref
        .read(fcmServiceProvider)
        .showPermanentNotification(
          title: 'Whomie',
          body: solution,
          payload: _sessionId,
        );

    // Showing the toast for the selected task
    if (mounted && context.mounted) {
      WhomieToast.success(context: context, text: 'Saved to your tasks!');
      await _stopConversation();

      // Update persistence provider and trigger save
      ref
          .read(voiceJournalPersistenceProvider.notifier)
          .updateState(
            messages: _messages,
            sessionId: _sessionId,
            mood: widget.initialMood,
            currentSolutions: _currentSolutions,
            advice: _lastAdvice,
            selectedSolution: solution,
          );
      await ref.read(voiceJournalPersistenceProvider.notifier).saveToDb();

      if (mounted && context.mounted) {
        Navigator.pop(context);
      }
    }
  }

  @override
  void dispose() {
    // Mark as disposed first to prevent any further recorder access
    _disposed = true;

    // Use a sync approach to avoid race conditions
    // Cancel any active mic subscription immediately
    _micSubscription?.cancel();
    _micSubscription = null;

    // Close stream controllers
    if (_geminiStreamController != null && !_geminiStreamController!.isClosed) {
      _geminiStreamController?.close();
      _geminiStreamController = null;
    }
    if (_stopController != null && !_stopController!.isClosed) {
      _stopController?.add(true);
      _stopController?.close();
    }

    // Dispose session and audio resources synchronously
    _session?.close();
    if (_audioSrc != null) {
      try {
        SoLoud.instance.setDataIsEnded(_audioSrc!);
      } catch (_) {}
    }
    if (_handle != null) {
      try {
        SoLoud.instance.stop(_handle!);
      } catch (_) {}
    }

    // Dispose recorder - it's safe now as we've cancelled subscriptions
    try {
      _recorder.dispose();
    } catch (e) {
      log('⚠️ Recorder dispose warning: $e');
    }

    _pulseController.dispose();
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    ref.listen(slowInternetProvider, (previous, next) {
      log('🌐 Internet Status Changed: $next');
      if (!_conversationActive) return;

      if (next == InternetStatus.noInternet) {
        WhomieToast.error(
          context: context,
          text: 'No internet connection',
          subtitle: 'Please check your connection',
        );
      } else if (next == InternetStatus.slow) {
        WhomieToast.warning(
          context: context,
          text: 'Slow internet detected',
          subtitle: 'You might experience some delays',
        );
      }
    });

    final optionsStream = ref
        .watch(journalRepositoryProvider)
        .watchOptionsBySessionId(_sessionId);

    return PopScope(
      canPop: false,
      onPopInvokedWithResult: (didPop, result) async {
        if (!didPop) return;
        if (_messages.isNotEmpty) {
          // Update persistence provider and trigger save
          ref
              .read(voiceJournalPersistenceProvider.notifier)
              .updateState(
                messages: _messages,
                sessionId: _sessionId,
                mood: widget.initialMood,
                currentSolutions: _currentSolutions,
                advice: _lastAdvice,
              );
          await ref.read(voiceJournalPersistenceProvider.notifier).saveToDb();
        }
        await _stopConversation(updateUi: false);
        if (context.mounted) {
          Navigator.of(context).pop();
        }
      },
      child: Container(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [
              Colors.white,
              const Color(0xFFF8FBFF),
              AppColors.primary.withValues(alpha: 0.05),
            ],
          ),
        ),
        child: WhomieScaffold(
          backgroundColor: Colors.transparent,
          appBar: WhomieAppBar(
            title: 'Whomie Voice',
            actions: [
              if (FeatureFlags.isPausePlayVoiceAI && _currentSolutions.isEmpty)
                IconButton(
                  onPressed: _settingUpSession ? null : _toggleConversation,
                  icon: Icon(
                    _conversationActive
                        ? Icons.pause_circle_filled
                        : Icons.play_circle_filled,
                    color: AppColors.primary,
                    size: 32,
                  ),
                ),
              if (_conversationActive)
                Padding(
                  padding: const EdgeInsets.only(right: 16),
                  child: PhosphorIcon(
                    PhosphorIcons.broadcast(PhosphorIconsStyle.fill),
                    color: Colors.redAccent,
                  ),
                ),
            ],
          ),
          body: Column(
            children: [
              Expanded(
                flex: 3,
                child: _messages.isEmpty
                    ? Center(
                        child: Column(
                          mainAxisAlignment: MainAxisAlignment.center,
                          children: [
                            Icon(
                              PhosphorIcons.microphone(PhosphorIconsStyle.fill),
                              size: 80,
                              color: AppColors.primaryDark.withValues(
                                alpha: 0.8,
                              ),
                            ),
                            const SizedBox(height: 24),
                            Text(
                              _conversationActive
                                  ? 'Whomie is listening...'
                                  : 'Please wait, Whomie is starting',
                              style: TextStyle(
                                color: AppColors.textPrimary.withValues(
                                  alpha: 0.4,
                                ),
                                fontSize: 18,
                                fontWeight: FontWeight.w500,
                                letterSpacing: 0.5,
                              ),
                            ),
                          ],
                        ),
                      )
                    : ListView.builder(
                        controller: _scrollController,
                        padding: const EdgeInsets.all(16),
                        itemCount: _messages.length,
                        itemBuilder: (context, index) {
                          final msg = _messages[index];
                          final isUser = msg['role'] == 'user';
                          final userId = ref
                              .read(authRepositoryProvider)
                              .currentUser
                              ?.uid;

                          return Column(
                            crossAxisAlignment: isUser
                                ? CrossAxisAlignment.end
                                : CrossAxisAlignment.start,
                            children: [
                              Row(
                                mainAxisAlignment: isUser
                                    ? MainAxisAlignment.end
                                    : MainAxisAlignment.start,
                                crossAxisAlignment: CrossAxisAlignment.end,
                                children: [
                                  if (!isUser) ...[
                                    CircleAvatar(
                                      radius: 16,
                                      backgroundColor: Colors.white,
                                      child: SvgPicture.asset(
                                        ImagePath.svgLogo,
                                        fit: BoxFit.cover,
                                      ),
                                    ),
                                    const SizedBox(width: 8),
                                  ],
                                  Flexible(
                                    child: Container(
                                      margin: const EdgeInsets.symmetric(
                                        vertical: 4,
                                      ),
                                      padding: const EdgeInsets.all(12),
                                      decoration: BoxDecoration(
                                        color: isUser
                                            ? AppColors.primaryDark.withValues(
                                                alpha: 0.9,
                                              )
                                            : Colors.white.withValues(
                                                alpha: 0.7,
                                              ),
                                        borderRadius: BorderRadius.circular(20),
                                        border: Border.all(
                                          color: isUser
                                              ? Colors.white.withValues(
                                                  alpha: 0.1,
                                                )
                                              : Colors.white.withValues(
                                                  alpha: 0.4,
                                                ),
                                          width: 1,
                                        ),
                                        boxShadow: [
                                          BoxShadow(
                                            color: Colors.black.withValues(
                                              alpha: 0.05,
                                            ),
                                            blurRadius: 10,
                                            offset: const Offset(0, 4),
                                          ),
                                        ],
                                      ),
                                      child: RichText(
                                        text: TextHelper.parseMarkdown(
                                          msg['content'] ?? '',
                                          TextStyle(
                                            color: isUser
                                                ? Colors.white
                                                : AppColors.textPrimary,
                                            fontSize: 16,
                                            height: 1.4,
                                          ),
                                        ),
                                      ),
                                    ),
                                  ),
                                  if (isUser) ...[
                                    const SizedBox(width: 8),
                                    Consumer(
                                      builder: (context, ref, _) {
                                        final profilePictureAsync =
                                            userId != null
                                            ? ref.watch(
                                                profilePicturePathProvider(
                                                  userId,
                                                ),
                                              )
                                            : const AsyncValue.data(null);

                                        return profilePictureAsync.when(
                                          data: (path) {
                                            if (path != null &&
                                                File(path).existsSync()) {
                                              return CircleAvatar(
                                                radius: 16,
                                                backgroundImage: FileImage(
                                                  File(path),
                                                ),
                                              );
                                            }
                                            return CircleAvatar(
                                              radius: 16,
                                              backgroundColor:
                                                  AppColors.primaryDark,
                                              child: PhosphorIcon(
                                                PhosphorIcons.user(
                                                  PhosphorIconsStyle.regular,
                                                ),
                                                size: 16,
                                                color: Colors.white,
                                              ),
                                            );
                                          },
                                          loading: () =>
                                              const SizedBox(width: 32),
                                          error: (_, __) =>
                                              const SizedBox(width: 32),
                                        );
                                      },
                                    ),
                                  ],
                                ],
                              ),
                              const SizedBox(height: 8),
                            ],
                          );
                        },
                      ),
              ),
              StreamBuilder<ActionOption?>(
                stream: optionsStream,
                builder: (context, snapshot) {
                  final options = snapshot.data;
                  final List<String> displaySolutions =
                      _currentSolutions.isNotEmpty
                      ? _currentSolutions.map((s) => s['text'] ?? '').toList()
                      : (options != null
                            ? [
                                options.optionA ?? '',
                                options.optionB ?? '',
                                options.optionC ?? '',
                              ].where((s) => s.isNotEmpty).toList()
                            : []);

                  if (displaySolutions.isEmpty) {
                    return Expanded(
                      flex: 1,
                      child: Center(
                        child: _settingUpSession
                            ? const CircularProgressIndicator(
                                color: AppColors.primary,
                              )
                            : _conversationActive
                            ? Lottie.asset(
                                'assets/lottie/voice_visualization.json',
                                width: double.infinity,
                                height: 120,
                                fit: BoxFit.contain,
                                delegates: LottieDelegates(
                                  values: [
                                    ValueDelegate.color(
                                      const ['**', 'Fill 1'],
                                      value: _isAiSpeaking
                                          ? Colors.greenAccent
                                          : Colors.orangeAccent,
                                    ),
                                    ValueDelegate.color(
                                      const ['**', 'Fill'],
                                      value: _isAiSpeaking
                                          ? Colors.greenAccent
                                          : Colors.orangeAccent,
                                    ),
                                    ValueDelegate.color(
                                      const ['**', 'Stroke 1'],
                                      value: _isAiSpeaking
                                          ? Colors.greenAccent
                                          : Colors.orangeAccent,
                                    ),
                                  ],
                                ),
                              )
                            : const SizedBox.shrink(),
                      ),
                    );
                  }

                  return Padding(
                    padding: const EdgeInsets.only(
                      left: 12,
                      right: 12,
                      bottom: 48,
                    ),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Container(
                          padding: const EdgeInsets.symmetric(
                            vertical: 12,
                            horizontal: 12,
                          ),
                          margin: const EdgeInsets.only(bottom: 8, top: 8),
                          decoration: BoxDecoration(
                            color: AppColors.secondary,
                            borderRadius: BorderRadius.circular(12),
                          ),
                          child: Text(
                            (widget.sessionId == null)
                                ? "Select an option provided by Whomie"
                                : "Actions provided by Whomie",
                            style: TextStyle(
                              color: AppColors.white,
                              fontSize: 17,
                              fontWeight: FontWeight.bold,
                            ),
                          ),
                        ),
                        const SizedBox(height: 12),
                        ...displaySolutions.map((sol) {
                          final isSelected =
                              options != null &&
                              options.selectedOption?.trim() ==
                                  sol.toString().trim();
                          return Padding(
                            padding: const EdgeInsets.only(bottom: 12),
                            child: Material(
                              color: Colors.transparent,
                              child: InkWell(
                                onTap: widget.sessionId != null
                                    ? null
                                    : () => _onSolutionSelected(
                                        sol.toString().trim(),
                                      ),
                                borderRadius: BorderRadius.circular(16),
                                child: Container(
                                  padding: const EdgeInsets.symmetric(
                                    horizontal: 14,
                                    vertical: 12,
                                  ),
                                  decoration: BoxDecoration(
                                    color: const Color(0xFFFFF0ED),
                                    borderRadius: BorderRadius.circular(12),
                                    border: Border.all(
                                      color: isSelected
                                          ? AppColors.secondary
                                          : AppColors.secondary.withValues(
                                              alpha: 0.4,
                                            ),
                                      width: isSelected ? 2.5 : 1.5,
                                    ),
                                    boxShadow: const [
                                      BoxShadow(
                                        color: Colors.black12,
                                        blurRadius: 4,
                                        offset: Offset(0, 2),
                                      ),
                                    ],
                                  ),
                                  child: Row(
                                    children: [
                                      Expanded(
                                        child: Text(
                                          sol.toString(),
                                          style: const TextStyle(
                                            color: AppColors.textPrimary,
                                            fontWeight: FontWeight.bold,
                                            fontSize: 15,
                                          ),
                                        ),
                                      ),
                                      if (widget.sessionId == null) ...[
                                        const SizedBox(width: 8),
                                        Container(
                                          padding: const EdgeInsets.all(4),
                                          decoration: const BoxDecoration(
                                            color: AppColors.secondary,
                                            shape: BoxShape.circle,
                                          ),
                                          child: const Icon(
                                            Icons.arrow_forward_rounded,
                                            size: 14,
                                            color: Colors.white,
                                          ),
                                        ),
                                      ],
                                    ],
                                  ),
                                ),
                              ),
                            ),
                          );
                        }),
                        if (widget.sessionId == null)
                          Padding(
                            padding: const EdgeInsets.only(top: 8, bottom: 4),
                            child: Center(
                              child: TextButton(
                                onPressed: () async {
                                  if (_messages.isNotEmpty) {
                                    // Update persistence provider and trigger save
                                    ref
                                        .read(
                                          voiceJournalPersistenceProvider
                                              .notifier,
                                        )
                                        .updateState(
                                          messages: _messages,
                                          sessionId: _sessionId,
                                          mood: widget.initialMood,
                                          currentSolutions: _currentSolutions,
                                          advice: _lastAdvice,
                                        );
                                    await ref
                                        .read(
                                          voiceJournalPersistenceProvider
                                              .notifier,
                                        )
                                        .saveToDb();
                                  }
                                  if (mounted && context.mounted) context.pop();
                                },
                                style: TextButton.styleFrom(
                                  padding: const EdgeInsets.symmetric(
                                    horizontal: 24,
                                    vertical: 12,
                                  ),
                                ),
                                child: Text(
                                  'No Action',
                                  style: TextStyle(
                                    fontWeight: FontWeight.w500,
                                    fontSize: 14,
                                    color: AppColors.black,
                                    decoration: TextDecoration.underline,
                                    decorationColor: AppColors.black,
                                  ),
                                ),
                              ),
                            ),
                          ),
                      ],
                    ),
                  );
                },
              ),
            ],
          ),
        ),
      ),
    );
  }

  String _getMoodLabel(double value) {
    final index = (value.round() - 1).clamp(0, 6);
    const labels = [
      'Depressed',
      'Very Bad',
      'Bad',
      'Okay',
      'Good',
      'Great',
      'Awesome',
    ];
    return labels[index];
  }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions