import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'cam_screen.dart'; import 'onboarding_screen.dart'; import 'package:record/record.dart'; import 'package:audioplayers/audioplayers.dart'; import 'package:flutter_tts/flutter_tts.dart'; import 'package:path_provider/path_provider.dart'; import 'package:speech_to_text/speech_to_text.dart' as stt; import '../models/mood.dart'; import '../widgets/capivara_scene.dart'; import '../widgets/waves_painter.dart'; // moodColors map import '../services/maick_service.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:android_intent_plus/android_intent.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; class HomeScreen extends StatefulWidget { final String? ssoToken; const HomeScreen({super.key, this.ssoToken}); @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State with TickerProviderStateMixin, WidgetsBindingObserver { late AnimationController _turnController; late Animation _turnAnim; // Gravação principal final AudioRecorder _recorder = AudioRecorder(); bool _recorderReady = false; String? _recordingPath; Timer? _recordingTimer; double _soundLevel = 0.0; StreamSubscription? _ampSub; bool _hasSpoken = false; int _silenceFrames = 0; static const _silenceThresholdDb = -42.0; static const _silenceTarget = 15; // Wake word final stt.SpeechToText _speechWake = stt.SpeechToText(); bool _speechWakeReady = false; bool _wakeActive = false; // TTS final AudioPlayer _audio = AudioPlayer(); final FlutterTts _tts = FlutterTts(); bool _isSpeaking = false; bool _followUpMode = false; // aguardando follow-up após resposta Timer? _followUpTimer; // timer para voltar ao wake word bool _showTextInput = false; // mostra campo de texto (opcional) bool _ttsReady = false; Mood _currentMood = Mood.calm; double _energy = 0.15; double _eyeBrow = 0.0; String _maickText = ''; bool _isListening = false; bool _isThinking = false; bool _isAssistant = false; // app é o assistente digital padrão final TextEditingController _inputController = TextEditingController(); final ScrollController _textScroll = ScrollController(); // Texto com destaque de sentença conforme fala List _sentences = []; int _highlightedIdx = -1; Timer? _highlightTimer; Timer? _notifTimer; // WebSocket — entrega instantânea de mensagens e notas de voz WebSocketChannel? _wsChannel; StreamSubscription? _wsSub; Timer? _wsPingTimer; Timer? _wsReconnTimer; bool _wsConnected = false; // Voice Note mode — grava e envia para outro usuário bool _voiceNoteMode = false; String _voiceNoteTarget = ''; bool _specialMode = false; static const _specialSilenceTarget = 30; String? _pendingConfirm; static const _platform = MethodChannel('br.com.elwnet.mikeai/service'); bool get _isBusy => _isListening || _isThinking || _isSpeaking; // ── INIT ──────────────────────────────────────────────────────────────── @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _turnController = AnimationController( vsync: this, duration: const Duration(milliseconds: 600), ); _turnAnim = CurvedAnimation(parent: _turnController, curve: Curves.easeInOut); _audio.onPlayerStateChanged.listen((s) { if (!mounted) return; final wasPlaying = _isSpeaking; setState(() => _isSpeaking = s == PlayerState.playing); // Quando áudio termina: reinicia wake word se não está ocupado if (wasPlaying && s != PlayerState.playing) { Future.delayed(const Duration(milliseconds: 600), () { if (mounted && !_isBusy && !_wakeActive) _startWakeWord(); }); } }); _initTts(); _initRecorder(); _platform.setMethodCallHandler(_handleNativeCall); if (widget.ssoToken != null) MaickService.login(widget.ssoToken!); MaickService.loadSession().then((_) async { final prefs = await SharedPreferences.getInstance(); _specialMode = prefs.getBool('special_mode') ?? false; if (MaickService.userName == 'lorena') _specialMode = true; MaickService.specialMode = _specialMode; Future.delayed(const Duration(milliseconds: 900), _greet); _connectWs(); // inicia WebSocket após ter token }); _startNotifPoll(); _startForegroundService(); } Future _initTts() async { try { final engines = await _tts.getEngines; if (engines != null) { final g = (engines as List).firstWhere( (e) => e.toString().toLowerCase().contains('google'), orElse: () => null, ); if (g != null) await _tts.setEngine(g.toString()); } } catch (_) {} await _tts.setLanguage('pt-BR'); await _tts.setSpeechRate(0.42); await _tts.setPitch(1.05); await _tts.setVolume(1.0); _tts.setCompletionHandler(() { if (mounted) setState(() => _isSpeaking = false); }); _tts.setCancelHandler(() { if (mounted) setState(() => _isSpeaking = false); }); _tts.setStartHandler(() { if (mounted) setState(() => _isSpeaking = true); }); _tts.setProgressHandler((text, start, end, word) { // Destaca sentença conforme progresso do TTS if (mounted && _sentences.isNotEmpty) { int pos = 0; for (int i = 0; i < _sentences.length; i++) { pos += _sentences[i].length + 1; if (start < pos) { if (_highlightedIdx != i) setState(() => _highlightedIdx = i); _scrollToHighlight(); break; } } } }); if (mounted) setState(() => _ttsReady = true); } Future _initRecorder() async { _recorderReady = await _recorder.hasPermission(); if (mounted) setState(() {}); _speechWakeReady = await _speechWake.initialize( onStatus: (status) { if ((status == 'done' || status == 'notListening') && _wakeActive && !_isBusy && mounted) { _wakeActive = false; Future.delayed(const Duration(milliseconds: 600), _startWakeWord); } }, onError: (error) { if (_wakeActive && !_isBusy && mounted) { _wakeActive = false; Future.delayed(const Duration(seconds: 2), _startWakeWord); } }, ); bool isAssistant = false; try { isAssistant = await _platform.invokeMethod('isAssistantApp') ?? false; } catch (_) {} _isAssistant = isAssistant; if (_recorderReady && !isAssistant) _startWakeWord(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _turnController.dispose(); _inputController.dispose(); _textScroll.dispose(); _recordingTimer?.cancel(); _ampSub?.cancel(); _notifTimer?.cancel(); _followUpTimer?.cancel(); _highlightTimer?.cancel(); _wsReconnTimer?.cancel(); _wsPingTimer?.cancel(); _wsSub?.cancel(); _wsChannel?.sink.close(); _stopForegroundService(); _recorder.dispose(); _speechWake.cancel(); _audio.dispose(); _tts.stop(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { if (!_isBusy && !_wakeActive) { _platform.invokeMethod('isAssistantApp').then((isAssistant) { if (!(isAssistant ?? false) && !_isBusy && !_wakeActive && mounted) { _startWakeWord(); } }).catchError((_) { if (!_isBusy && !_wakeActive && mounted) _startWakeWord(); }); } } } Future _handleNativeCall(MethodCall call) async { if (call.method == 'assistantActivated' && mounted) { _stopWakeWord(); // Para qualquer áudio em andamento (greeting, resposta) — ativação do assistente tem prioridade await _audio.stop(); await _tts.stop(); _highlightTimer?.cancel(); if (mounted) setState(() { _isSpeaking = false; _isThinking = false; }); await Future.delayed(const Duration(milliseconds: 300)); if (mounted) { _faceViewer(); _startListening(); } } else if (call.method == 'wakeWordBackground' && mounted) { // Wake word detectado pelo servico nativo em background _stopWakeWord(); await _audio.stop(); await _tts.stop(); _highlightTimer?.cancel(); if (mounted) setState(() { _isSpeaking = false; _isThinking = false; }); await Future.delayed(const Duration(milliseconds: 400)); if (mounted) { _faceViewer(); _setMood(Mood.happy, energy: 0.5); _setDisplayText('Ouvindo...'); await _speak('Oi, pode falar!', Mood.happy); await Future.delayed(const Duration(milliseconds: 200)); if (mounted) _startListening(); } } } // ── WAKE WORD ──────────────────────────────────────────────────────────── void _startWakeWord() { if (_isAssistant) return; // assistente padrão: ativação vem do Home, não wake word if (_isBusy || _wakeActive) return; if (!_speechWakeReady) return; _wakeActive = true; _speechWake.listen( onResult: (result) async { if (!_isBusy && result.recognizedWords.isNotEmpty) { final lower = result.recognizedWords.toLowerCase(); if (RegExp(r'ma[iy][ck]|maique|maik|mike|miqueai|mikeai').hasMatch(lower)) { _stopWakeWord(); _faceViewer(); try { await _platform.invokeMethod('wakeScreen'); } catch (_) {} await Future.delayed(const Duration(milliseconds: 300)); if (mounted) { setState(() => _maickText = 'Ouvindo...'); _setMood(Mood.happy, energy: 0.5); // Feedback de voz: fala "Oi!" antes de ligar o mic await _speak('Oi, pode falar!', Mood.happy); await Future.delayed(const Duration(milliseconds: 200)); if (mounted) _startListening(); } } } }, localeId: 'pt_BR', listenOptions: stt.SpeechListenOptions( listenMode: stt.ListenMode.dictation, cancelOnError: false, ), ); } // ── NOTIFICAÇÕES ───────────────────────────────────────────────────────── void _startNotifPoll() { _notifTimer?.cancel(); _notifTimer = Timer(const Duration(seconds: 5), () { _checkNotifications(); _notifTimer = Timer.periodic(const Duration(seconds: 30), (_) => _checkNotifications()); }); } Future _checkNotifications() async { final notifs = await MaickService.getNotifications(); if (!mounted || notifs.isEmpty) return; final parts = notifs.map((n) { final from = n['from'] as String? ?? 'alguem'; final msg = n['message'] as String? ?? ''; return msg.isNotEmpty ? 'Mensagem de $from: $msg' : ''; }).where((t) => t.isNotEmpty).toList(); if (parts.isEmpty) return; final full = parts.join('. '); if (mounted) { _setMood(Mood.happy, energy: 0.6, eyeBrow: 0.2); _setDisplayText(full); } if (!_isBusy) _speak(full, Mood.happy); } // ── FOREGROUND SERVICE ────────────────────────────────────────────────── Future _startForegroundService() async { try { await _platform.invokeMethod('startForeground'); } catch (_) {} } Future _stopForegroundService() async { try { await _platform.invokeMethod('stopForeground'); } catch (_) {} } void _stopWakeWord() { _wakeActive = false; try { _speechWake.stop(); } catch (_) {} } // ── DESTAQUE DE TEXTO ──────────────────────────────────────────────────── // Remove JSON parcial/completo que o LLM está construindo ao final da resposta String _stripStreamingJson(String text) { final idx = text.lastIndexOf('\n{'); if (idx >= 0) return text.substring(0, idx).trim(); // JSON sem newline antecedente (raro, mas cobre edge case) final idx2 = text.lastIndexOf('{'); if (idx2 > 0 && idx2 > text.length - 200) { final before = text.substring(0, idx2); if (!before.contains('{')) return before.trim(); } return text; } void _setDisplayText(String text) { if (!mounted) return; // Quebra em sentenças para destaque progressivo final sents = text .split(RegExp(r'(?<=[.!?])\s+')) .where((s) => s.trim().isNotEmpty) .toList(); setState(() { _maickText = text; _sentences = sents; _highlightedIdx = -1; }); } void _startSpeechHighlight(String text, Duration totalDuration) { _highlightTimer?.cancel(); if (_sentences.isEmpty) return; final msPerSent = totalDuration.inMilliseconds ~/ _sentences.length; int idx = 0; _highlightTimer = Timer.periodic(Duration(milliseconds: msPerSent), (t) { if (!mounted || idx >= _sentences.length) { t.cancel(); return; } setState(() => _highlightedIdx = idx); _scrollToHighlight(); idx++; }); } void _scrollToHighlight() { if (!_textScroll.hasClients) return; // Estima posição: ~22px por linha, ~40 chars por linha if (_sentences.isEmpty || _highlightedIdx < 0) return; int charsBefore = 0; for (int i = 0; i < _highlightedIdx && i < _sentences.length; i++) { charsBefore += _sentences[i].length; } final linesBeforeHighlight = charsBefore / 38; final targetOffset = linesBeforeHighlight * 24.0; _textScroll.animateTo( targetOffset.clamp(0, _textScroll.position.maxScrollExtent), duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } // ── GRAVAÇÃO / STT ─────────────────────────────────────────────────────── // ── WEBSOCKET ──────────────────────────────────────────────────────────── void _connectWs() { final url = MaickService.wsUrl; if (url == null || !mounted) return; _wsReconnTimer?.cancel(); try { _wsChannel = WebSocketChannel.connect(Uri.parse(url)); _wsSub?.cancel(); _wsSub = _wsChannel!.stream.listen( _handleWsMessage, onDone: () { if (mounted) { setState(() => _wsConnected = false); _scheduleWsReconnect(); } }, onError: (_) { if (mounted) { setState(() => _wsConnected = false); _scheduleWsReconnect(); } }, ); setState(() => _wsConnected = true); // Ping a cada 25s para manter conexão viva _wsPingTimer?.cancel(); _wsPingTimer = Timer.periodic(const Duration(seconds: 25), (_) { try { _wsChannel?.sink.add('ping'); } catch (_) {} }); } catch (_) { _scheduleWsReconnect(); } } void _scheduleWsReconnect() { _wsReconnTimer?.cancel(); _wsReconnTimer = Timer(const Duration(seconds: 8), () { if (mounted) _connectWs(); }); } Future _handleWsMessage(dynamic raw) async { if (!mounted) return; try { final msg = jsonDecode(raw as String) as Map; final type = msg['type'] as String? ?? ''; final from = (msg['from'] as String? ?? 'alguém').toLowerCase(); final fromName = from[0].toUpperCase() + from.substring(1); if (type == 'pong') return; if (type == 'notify') { // Mensagem de texto — fala automaticamente, sem nenhum botão final text = msg['text'] as String? ?? ''; if (text.isEmpty) return; final display = 'Mensagem de $fromName: $text'; _setMood(Mood.happy, energy: 0.6, eyeBrow: 0.2); _setDisplayText(display); if (!_isBusy) { _speak(display, Mood.happy); } else { // Aguarda o Maick terminar e toca logo depois Future.delayed(const Duration(milliseconds: 300), () { if (mounted) _speak(display, Mood.happy); }); } } else if (type == 'voice_note') { // Nota de voz — baixa e toca automaticamente final audioId = msg['audio_id'] as String? ?? ''; if (audioId.isEmpty) return; final announce = 'Mensagem de voz de $fromName'; _setMood(Mood.happy, energy: 0.7); _setDisplayText(announce); _speak(announce, Mood.happy).then((_) async { // Depois do anúncio, toca o áudio da nota de voz final bytes = await MaickService.fetchVoiceNote(audioId); if (bytes != null && bytes.isNotEmpty && mounted) { await _audio.stop(); setState(() => _isSpeaking = true); await _audio.play(BytesSource(bytes)); } }); } else if (type == 'reminder') { // Lembrete disparado pelo servidor final text = msg['text'] as String? ?? ''; if (text.isEmpty) return; _setMood(Mood.thinking, energy: 0.6, eyeBrow: 0.1); _setDisplayText('Lembrete: $text'); final fala = 'Lembrete! $text'; if (!_isBusy) { _speak(fala, Mood.thinking); } else { Future.delayed(const Duration(milliseconds: 500), () { if (mounted) _speak(fala, Mood.thinking); }); } } else if (type == 'emergency') { // Alerta de emergência do servidor — fala imediatamente, interrompendo tudo final text = msg['text'] as String? ?? ''; final level = msg['level'] as String? ?? 'warning'; if (text.isEmpty) return; final isCritical = level == 'critical'; // Para o que estiver falando e fala o alerta imediatamente await _tts.stop(); _setMood(isCritical ? Mood.nervous : Mood.worried, energy: 0.9, eyeBrow: 0.8); _setDisplayText(isCritical ? '🚨 ' : '⚠️ '); await _speak(text, isCritical ? Mood.nervous : Mood.worried); } } catch (_) {} } // ── VOICE NOTE MODE ────────────────────────────────────────────────────── /// Ativado quando Maick recebe ação "voice_note_request:destinatário" /// Grava o áudio e envia direto para o destinatário Future _startVoiceNote(String target) async { if (!_recorderReady) return; _voiceNoteMode = true; _voiceNoteTarget = target; final targetName = target[0].toUpperCase() + target.substring(1); _setMood(Mood.happy, energy: 0.5); _setDisplayText('Gravando para $targetName...'); await _speak('Pode falar, eu mando para $targetName!', Mood.happy); await _startListening(); // Quando _stopAndTranscribe terminar, _finishVoiceNote é chamado // Usamos um hook no flag _voiceNoteMode } /// Chamado após gravação terminar — envia áudio direto, sem STT Future _finishVoiceNote(Uint8List audioBytes) async { final target = _voiceNoteTarget; _voiceNoteMode = false; _voiceNoteTarget = ''; final targetName = target[0].toUpperCase() + target.substring(1); if (mounted) { _setMood(Mood.thinking, energy: 0.5); _setDisplayText('Enviando para $targetName...'); } final ok = await MaickService.sendVoiceNote(audioBytes, target); if (mounted) { final msg = ok ? 'Mensagem enviada para $targetName!' : 'Erro ao enviar para $targetName'; _setMood(ok ? Mood.happy : Mood.worried, energy: 0.6); _setDisplayText(msg); _speak(msg, ok ? Mood.happy : Mood.worried); } } void _toggleListening() => _isListening ? _stopAndTranscribe() : _startListening(); Future _startListening() async { _followUpTimer?.cancel(); // Toque no mic sempre reseta qualquer estado travado if (mounted) setState(() { _followUpMode = false; _isThinking = false; _isSpeaking = false; }); if (!_recorderReady) { _recorderReady = await _recorder.hasPermission(); if (!_recorderReady) return; } _stopWakeWord(); await _audio.stop(); await _tts.stop(); _highlightTimer?.cancel(); final dir = await getTemporaryDirectory(); _recordingPath = '${dir.path}/maick_stt.m4a'; final f = File(_recordingPath!); if (await f.exists()) await f.delete(); await _recorder.start( const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000, numChannels: 1), path: _recordingPath!, ); _hasSpoken = false; _silenceFrames = 0; if (mounted) setState(() { _isListening = true; _soundLevel = 0; }); _setMood(Mood.calm, energy: 0.3); _faceViewer(); _ampSub = _recorder.onAmplitudeChanged(const Duration(milliseconds: 100)).listen((amp) { if (!_isListening || !mounted) return; final n = ((amp.current + 60) / 60).clamp(0.0, 1.0); setState(() { _soundLevel = n; _energy = 0.2 + n * 0.8; }); if (amp.current > _silenceThresholdDb) { _hasSpoken = true; _silenceFrames = 0; } else if (_hasSpoken) { _silenceFrames++; final target = _specialMode ? _specialSilenceTarget : _silenceTarget; if (_silenceFrames >= target) _stopAndTranscribe(); } }); _recordingTimer = Timer(const Duration(seconds: 30), _stopAndTranscribe); } Future _stopAndTranscribe() async { _recordingTimer?.cancel(); _ampSub?.cancel(); _ampSub = null; if (!_isListening) return; if (mounted) setState(() { _isListening = false; _soundLevel = 0; }); final path = await _recorder.stop(); if (path == null) { _startWakeWord(); return; } final audioFile = File(path); if (!await audioFile.exists()) { _startWakeWord(); return; } final bytes = await audioFile.readAsBytes(); if (bytes.isEmpty) { _startWakeWord(); return; } // Voice Note mode: envia áudio direto sem STT if (_voiceNoteMode) { _finishVoiceNote(bytes); return; } _setMood(Mood.thinking, energy: 0.4); final audioKb = bytes.length ~/ 1024; if (mounted) setState(() { _isThinking = true; _maickText = 'processando... (${audioKb}KB)'; }); final text = await MaickService.sttFromBytes(bytes); if (text == null || text.trim().isEmpty || text.startsWith('ERR:') || text.startsWith('HTTP')) { final reason = text ?? 'STT null'; if (mounted) setState(() { _isThinking = false; _maickText = '$reason (${audioKb}KB)'; }); _setMood(Mood.worried, energy: 0.3); _startWakeWord(); return; } if (mounted) setState(() => _maickText = '"$text"'); await Future.delayed(const Duration(milliseconds: 500)); _sendMessage(text); } // ── TTS ────────────────────────────────────────────────────────────────── Future _speak(String text, [Mood? mood]) async { if (text.isEmpty || !mounted) return; await _audio.stop(); await _tts.stop(); // _isSpeaking NÃO é setado aqui — só quando algo realmente toca final moodName = (mood ?? _currentMood).name; bool played = false; try { final bytes = await MaickService.fetchTts(text, moodName) .timeout(const Duration(seconds: 15)); if (bytes != null && bytes.isNotEmpty) { // Inicia destaque baseado em duração estimada (150 palavras/min) final wordCount = text.split(' ').length; final estMs = (wordCount / 2.5 * 1000).round(); // ~150 words/min _startSpeechHighlight(text, Duration(milliseconds: estMs)); if (mounted) setState(() => _isSpeaking = true); // só seta quando vai tocar await _audio.play(BytesSource(bytes)); played = true; } } catch (e) { if (mounted) setState(() { _isSpeaking = false; _maickText = '[TTS err: $e]'.substring(0, 40.clamp(0, '[TTS err: $e]'.length)); }); } if (!played) { final clean = text.replaceAll(RegExp(r'[^\x00-\x7F\u00C0-\u024F\s.,!?]'), ''); if (clean.trim().isNotEmpty) { await _tts.speak(clean.trim()); // flutter_tts handlers gerenciam _isSpeaking } else { // Nada tocou — garante que _isSpeaking fica false if (mounted) setState(() => _isSpeaking = false); } } } // ── CHAT ───────────────────────────────────────────────────────────────── void _setMood(Mood mood, {double energy = 0.3, double eyeBrow = 0.0}) { if (!mounted) return; setState(() { _currentMood = mood; _energy = energy; _eyeBrow = eyeBrow; }); } void _faceViewer() { if (_turnController.status != AnimationStatus.completed) { _turnController.forward(); } } void _faceAway() { Future.delayed(const Duration(seconds: 4), () { if (mounted && !_isBusy) _turnController.reverse(); }); } void _greet() async { final h = DateTime.now().hour; final who = MaickService.userName; final nome = who == 'maick_app' ? '' : ' ${who[0].toUpperCase()}${who.substring(1)}'; String saudacao; Mood mood; if (h < 12) { saudacao = 'Bom dia$nome!'; mood = Mood.happy; } else if (h < 18) { saudacao = 'Boa tarde$nome!'; mood = Mood.calm; } else { saudacao = 'Boa noite$nome!'; mood = Mood.tired; } if (who == 'lorena') { mood = Mood.lorena; } _setMood(mood, energy: 0.5); _faceViewer(); // Briefing só de manhã (entre 5h e 11h) — primeiro uso do dia if (h >= 5 && h < 12) { final prefs = await SharedPreferences.getInstance(); final today = DateTime.now().toIso8601String().substring(0, 10); final lastBriefing = prefs.getString('last_briefing_date') ?? ''; if (lastBriefing != today) { await prefs.setString('last_briefing_date', today); // Fala saudação primeiro, depois briefing _setDisplayText(saudacao); await _speak(saudacao, mood); final briefing = await MaickService.getBriefing(); if (briefing != null && briefing.isNotEmpty && mounted) { _setDisplayText(briefing); await _speak(briefing, mood); } _faceAway(); return; } } _setDisplayText(saudacao); _speak(saudacao, mood); _faceAway(); } // ── AÇÕES DO APP ───────────────────────────────────────────────────────── Future _handleActions(List actions) async { for (final action in actions) { if (action.startsWith('youtube:')) { final query = Uri.encodeComponent(action.substring(8).trim()); if (query.isNotEmpty) { // Tenta abrir app do YouTube; fallback para browser try { final appUri = Uri.parse('youtube://results?search_query=$query'); if (await canLaunchUrl(appUri)) { await launchUrl(appUri, mode: LaunchMode.externalApplication); } else { final webUri = Uri.parse('https://www.youtube.com/results?search_query=$query'); await launchUrl(webUri, mode: LaunchMode.externalApplication); } } catch (_) { final webUri = Uri.parse('https://www.youtube.com/results?search_query=${Uri.encodeComponent(action.substring(8).trim())}'); await launchUrl(webUri, mode: LaunchMode.externalApplication); } } } else if (action.startsWith('app:')) { await _launchApp(action.substring(4)); } else if (action.startsWith('open:')) { await _launchApp(action.substring(5)); } else if (action.startsWith('voice_note_request:')) { final target = action.substring('voice_note_request:'.length).trim(); if (target.isNotEmpty) await _startVoiceNote(target); } else if (action.startsWith('reminder:')) { await _createReminder(action.substring('reminder:'.length).trim()); } else if (action.startsWith('shopping.add:')) { await MaickService.addShoppingItem(action.substring('shopping.add:'.length).trim()); } else if (action.startsWith('shopping.remove:')) { await MaickService.removeShoppingItem(action.substring('shopping.remove:'.length).trim()); } else if (action.startsWith('memory.set:')) { final parts = action.substring('memory.set:'.length).split('|'); if (parts.length >= 2) { await MaickService.setMemory(parts[0].trim(), parts[1].trim()); } } } } // ── LEMBRETES ───────────────────────────────────────────────────────────── Future _createReminder(String spec) async { // spec: "TEXTO DO LEMBRETE" com extra minutes/hours/time parseado do CT120 // O CT120 já calculou o due_at e envia como "texto|due_at_iso" final parts = spec.split('|'); final text = parts[0].trim(); final dueAt = parts.length > 1 ? parts[1].trim() : ''; if (text.isEmpty) return; await MaickService.createReminder(text, dueAt); } Future _launchApp(String spec) async { // spec: whatsapp | youtube | maps:Loanda PR | com.some.package | etc final parts = spec.split(':'); final name = parts[0].toLowerCase().trim(); final param = parts.length > 1 ? parts.sublist(1).join(':').trim() : ''; // Mapa nome → package Android const pkgMap = { 'whatsapp': 'com.whatsapp', 'instagram': 'com.instagram.android', 'telegram': 'org.telegram.messenger', 'netflix': 'com.netflix.mediaclient', 'spotify': 'com.spotify.music', 'youtube': 'com.google.android.youtube', 'galeria': 'com.google.android.apps.photos', 'fotos': 'com.google.android.apps.photos', 'calculadora': 'com.google.android.calculator', 'calendario': 'com.google.android.calendar', 'gmail': 'com.google.android.gm', 'chrome': 'com.android.chrome', 'waze': 'com.waze', 'ifood': 'br.com.brainweb.ifood', 'uber': 'com.ubercab', 'settings': 'com.android.settings', 'maps': 'com.google.android.apps.maps', }; // 1ª tentativa: URL https que Android roteia para o app nativo (mais confiável no Android 11+) // Não depende de canLaunchUrl — usa launchUrl direto com externalApplication final httpFallback = { 'youtube': (p) => p.isNotEmpty ? 'https://www.youtube.com/results?search_query=${Uri.encodeComponent(p)}' : 'https://www.youtube.com/', 'whatsapp': (_) => 'https://wa.me/', 'instagram': (_) => 'https://www.instagram.com/', 'telegram': (_) => 'https://t.me/', 'spotify': (_) => 'https://open.spotify.com/', 'maps': (p) => p.isNotEmpty ? 'https://maps.google.com/?q=${Uri.encodeComponent(p)}' : 'https://maps.google.com/', 'browser': (p) => p.isNotEmpty ? (p.startsWith('http') ? p : 'https://$p') : 'https://google.com', }; // Câmera: intent especial if (name == 'camera') { try { const intent = AndroidIntent( action: 'android.media.action.IMAGE_CAPTURE', flags: [0x10000000], ); await intent.launch(); } catch (_) {} return; } // 2ª tentativa: AndroidIntent com package + MAIN + LAUNCHER category (mais confiável) final pkg = pkgMap[name] ?? (name.contains('.') ? name : null); if (pkg != null) { try { final intent = AndroidIntent( action: 'android.intent.action.MAIN', package: pkg, componentName: '', // deixa Android resolver o activity principal flags: [ 0x10000000, // FLAG_ACTIVITY_NEW_TASK 0x20000000, // FLAG_ACTIVITY_RESET_TASK_IF_NEEDED ], ); await intent.launch(); return; // sucesso } catch (_) {} } // 3ª tentativa: URL https (Android roteia para o app instalado) if (httpFallback.containsKey(name)) { try { final url = httpFallback[name]!(param); await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); return; } catch (_) {} } // 4ª tentativa: URL scheme nativo (youtube://, spotify://, etc.) final schemeFallback = { 'youtube': 'youtube://', 'whatsapp': 'whatsapp://', 'instagram': 'instagram://app', 'telegram': 'tg://resolve', 'spotify': 'spotify://', 'netflix': 'nflx://www.netflix.com/browse', }; if (schemeFallback.containsKey(name)) { try { await launchUrl(Uri.parse(schemeFallback[name]!), mode: LaunchMode.externalApplication); return; } catch (_) {} } // Último recurso: Play Store para instalar o app if (pkg != null) { try { await launchUrl(Uri.parse('https://play.google.com/store/apps/details?id=$pkg'), mode: LaunchMode.externalApplication); } catch (_) {} } } Future _sendMessage(String message) async { if (message.trim().isEmpty) return; _inputController.clear(); _setMood(Mood.thinking, energy: 0.4); if (mounted) setState(() { _isThinking = true; _maickText = ''; }); final buffer = StringBuffer(); final earlyBuf = StringBuffer(); Future? earlyFetch; String earlyText = ''; try { await for (final token in MaickService.sendMessage(message)) { buffer.write(token); earlyBuf.write(token); if (mounted) setState(() => _maickText = _stripStreamingJson(buffer.toString())); if (earlyFetch == null && earlyBuf.length > 20) { final s = earlyBuf.toString(); final m = RegExp(r'^([^{\n]{20,}[.!?])\s').firstMatch(s); if (m != null) { earlyText = m.group(1)!.trim(); earlyFetch = MaickService.fetchTts(earlyText, _currentMood.name); earlyBuf.clear(); earlyBuf.write(s.substring(m.end)); } } } final response = MaickService.parseResponse(buffer.toString()); final display = response.text.isNotEmpty ? response.text : buffer.toString(); if (mounted) setState(() { _isThinking = false; }); _setMood(response.mood, energy: response.energy, eyeBrow: response.eyeBrow); _setDisplayText(display); // Processar ações await _handleActions(response.actions); if (earlyFetch != null) { try { final earlyBytes = await earlyFetch!; if (earlyBytes != null && earlyBytes.isNotEmpty) { final rest = display.replaceFirst(earlyText, '').trim(); if (rest.isNotEmpty) { late StreamSubscription sub; sub = _audio.onPlayerComplete.listen((_) { sub.cancel(); if (mounted) _speak(rest, response.mood); }); } final wordCount = earlyText.split(' ').length; final estMs = (wordCount / 2.5 * 1000).round(); _startSpeechHighlight(display, Duration(milliseconds: estMs)); if (mounted) setState(() => _isSpeaking = true); try { await _audio.play(BytesSource(earlyBytes)); } catch (_) { if (mounted) setState(() => _isSpeaking = false); await _speak(display, response.mood); return; } return; } } catch (_) {} } await _speak(display, response.mood); } catch (_) { if (mounted) setState(() => _maickText = 'Erro de conexao. Tente novamente.'); _setMood(Mood.worried, energy: 0.3, eyeBrow: 0.5); } finally { if (mounted) setState(() => _isThinking = false); // Follow-up mode: Maick fica "pronto" por 5s após resposta // Usuário pode falar diretamente sem dizer "Maick" _followUpTimer?.cancel(); if (mounted) setState(() => _followUpMode = true); _followUpTimer = Timer(const Duration(seconds: 5), () { if (mounted) setState(() => _followUpMode = false); // Volta ao wake word quando áudio terminar Future.delayed(const Duration(milliseconds: 300), () { if (mounted && !_isBusy && !_wakeActive) { _startWakeWord(); _faceAway(); } }); }); } } // ── Barra de voz — design minimalista, voz é primária ───────────────────── Widget _buildVoiceBar() { final bool active = _isListening; final bool thinking = _isThinking || _isSpeaking; final bool followUp = _followUpMode && !active && !thinking; final bool wakeReady = _wakeActive && !active && !thinking && !followUp; return Padding( padding: const EdgeInsets.fromLTRB(0, 0, 0, 28), child: Column( mainAxisSize: MainAxisSize.min, children: [ // Campo de texto — aparece só quando solicitado AnimatedSize( duration: const Duration(milliseconds: 250), curve: Curves.easeOut, child: _showTextInput ? Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 14), child: Row( children: [ Expanded( child: Container( decoration: BoxDecoration( color: Colors.white.withOpacity(0.06), borderRadius: BorderRadius.circular(24), border: Border.all(color: _accent.withOpacity(0.3)), ), child: TextField( controller: _inputController, autofocus: true, style: const TextStyle( color: Colors.white, fontFamily: 'monospace', fontSize: 14), decoration: InputDecoration( hintText: 'escreva aqui...', hintStyle: TextStyle( color: Colors.white.withOpacity(0.25), fontSize: 13), contentPadding: const EdgeInsets.symmetric( horizontal: 18, vertical: 12), border: InputBorder.none, ), onSubmitted: (t) { _sendMessage(t); setState(() => _showTextInput = false); }, ), ), ), const SizedBox(width: 8), GestureDetector( onTap: () => setState(() => _showTextInput = false), child: Icon(Icons.close, color: _accent.withOpacity(0.6), size: 20), ), ], ), ) : const SizedBox.shrink(), ), // Status textual — discreto AnimatedSwitcher( duration: const Duration(milliseconds: 300), child: Text( active ? 'ouvindo...' : thinking ? (thinking ? '' : '') : followUp ? 'pode continuar...' : wakeReady ? 'diga Maick' : '', key: ValueKey(active ? 'a' : thinking ? 't' : followUp ? 'f' : 'w'), style: TextStyle( color: _accent.withOpacity(active ? 0.9 : followUp ? 0.7 : 0.35), fontSize: active ? 13 : 11, fontFamily: 'monospace', letterSpacing: 1.2, ), ), ), const SizedBox(height: 14), // Botões: teclado (esquerda) + mic (centro) + espaço (direita) Row( mainAxisAlignment: MainAxisAlignment.center, children: [ // Botão teclado — secundário, pequeno GestureDetector( onTap: () => setState(() => _showTextInput = !_showTextInput), child: AnimatedContainer( duration: const Duration(milliseconds: 200), width: 38, height: 38, decoration: BoxDecoration( shape: BoxShape.circle, color: _showTextInput ? _accent.withOpacity(0.15) : Colors.transparent, border: Border.all( color: _accent.withOpacity(0.25), width: 1.0, ), ), child: Icon( _showTextInput ? Icons.keyboard_hide : Icons.keyboard, color: _accent.withOpacity(0.35), size: 16, ), ), ), const SizedBox(width: 28), // Botão mic — principal, maior, com glow quando ativo GestureDetector( onTap: _toggleListening, child: Stack( alignment: Alignment.center, children: [ // Anel de pulso quando ativo ou follow-up if (active || followUp) AnimatedContainer( duration: const Duration(milliseconds: 400), width: active ? 76 : 64, height: active ? 76 : 64, decoration: BoxDecoration( shape: BoxShape.circle, color: _accent.withOpacity(active ? 0.12 : 0.06), ), ), // Botão mic AnimatedContainer( duration: const Duration(milliseconds: 200), width: 60, height: 60, decoration: BoxDecoration( shape: BoxShape.circle, color: active ? _accent.withOpacity(0.18) : thinking ? Colors.white.withOpacity(0.04) : Colors.transparent, border: Border.all( color: active ? _accent : _accent.withOpacity(thinking ? 0.2 : 0.5), width: active ? 2.5 : 1.5, ), boxShadow: active ? [BoxShadow( color: _accent.withOpacity(0.3), blurRadius: 16, spreadRadius: 2)] : [], ), child: Icon( active ? Icons.mic : thinking ? Icons.hourglass_empty : Icons.mic_none, color: active ? _accent : _accent.withOpacity(thinking ? 0.3 : 0.6), size: 26, ), ), ], ), ), const SizedBox(width: 28), // Espaço simétrico const SizedBox(width: 38, height: 38), ], ), ], ), ); } Color get _accent => (moodColors[_currentMood] ?? [const Color(0xFF0088CC)])[0]; // ── BUILD ───────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { final userName = MaickService.userName; return Scaffold( backgroundColor: Colors.black, body: SafeArea( child: Stack( children: [ Column( children: [ // ── Capivara — tela toda, texto só no balão ─────────── Expanded( child: GestureDetector( onTap: () { // Toque na capivara = falar diretamente (bypass wake word) if (!_isBusy) { _followUpTimer?.cancel(); setState(() { _followUpMode = false; _showTextInput = false; }); _startListening(); } }, child: CapivaraScene( mood: _currentMood, energy: _isListening ? (0.2 + _soundLevel * 0.8) : (_isThinking || _isSpeaking ? 0.6 : _energy), eyeBrow: _eyeBrow, headTurnAngle: _turnAnim.value, isListening: _isListening, isThinking: _isThinking, isSpeaking: _isSpeaking, speechText: _isListening ? (_hasSpoken ? 'ouvindo...' : 'pode falar...') : _maickText, ), ), ), // ── Barra de voz — primária ────────────────────────────────── _buildVoiceBar(), ], ), // Botão ELW Cam Positioned( top: 4, left: 8, child: GestureDetector( onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const CamScreen())), child: Padding( padding: const EdgeInsets.all(8), child: Icon(Icons.videocam_outlined, size: 20, color: Colors.white.withOpacity(0.25)), ), ), ), // Botão trocar perfil Positioned( top: 4, right: 8, child: GestureDetector( onTap: _switchProfile, child: Padding( padding: const EdgeInsets.all(8), child: Row(mainAxisSize: MainAxisSize.min, children: [ Text( userName.isNotEmpty ? userName[0].toUpperCase() + (userName.length > 1 ? userName[1] : '') : '?', style: TextStyle( color: Colors.white.withOpacity(0.25), fontSize: 11, fontFamily: 'monospace', ), ), const SizedBox(width: 4), Icon(Icons.person_outline, size: 16, color: Colors.white.withOpacity(0.2)), ]), ), ), ), ], ), ), ); } // Área de texto com destaque de sentença conforme fala Widget _buildTextDisplay() { if (_maickText.isEmpty && _isThinking) { return const Center( child: Text('...', style: TextStyle( color: Colors.white38, fontSize: 15, fontFamily: 'monospace', )), ); } if (_sentences.isEmpty || _highlightedIdx < 0) { // Texto simples sem destaque ainda return SingleChildScrollView( controller: _textScroll, child: Text( _maickText, style: const TextStyle( color: Colors.white60, fontSize: 15, fontFamily: 'monospace', height: 1.55, ), textAlign: TextAlign.center, ), ); } // Texto com destaque por sentença return SingleChildScrollView( controller: _textScroll, child: RichText( textAlign: TextAlign.center, text: TextSpan( children: List.generate(_sentences.length, (i) { final isHighlighted = i == _highlightedIdx; final isPast = i < _highlightedIdx; return TextSpan( text: '${_sentences[i]} ', style: TextStyle( color: isHighlighted ? Colors.white : (isPast ? Colors.white24 : Colors.white54), fontSize: 15, fontFamily: 'monospace', height: 1.55, fontWeight: isHighlighted ? FontWeight.w600 : FontWeight.normal, backgroundColor: isHighlighted ? _accent.withOpacity(0.18) : Colors.transparent, ), ); }), ), ), ); } Future _switchProfile() async { final confirm = await showDialog( context: context, builder: (_) => AlertDialog( backgroundColor: const Color(0xFF0A0A0A), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), title: const Text('Trocar perfil?', style: TextStyle(color: Colors.white, fontFamily: 'monospace', fontSize: 16)), content: Text('Usuário atual: ${MaickService.userName}', style: TextStyle(color: Colors.white.withOpacity(0.5), fontFamily: 'monospace', fontSize: 13)), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: Text('cancelar', style: TextStyle(color: Colors.white.withOpacity(0.3), fontFamily: 'monospace')), ), TextButton( onPressed: () => Navigator.pop(context, true), child: const Text('sair', style: TextStyle(color: Colors.redAccent, fontFamily: 'monospace')), ), ], ), ); if (confirm == true && mounted) { await MaickService.logout(); if (!mounted) return; Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (_) => const OnboardingScreen()), ); } } }