import 'dart:convert'; import 'dart:typed_data'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import '../models/mood.dart'; import '../models/maick_config.dart'; class MaickResponse { final String text; final Mood mood; final double eyeBrow; final double energy; final List actions; MaickResponse({ required this.text, this.mood = Mood.calm, this.eyeBrow = 0.0, this.energy = 0.3, this.actions = const [], }); } class MaickService { static String? _token; static int? _convId; static bool _loggingIn = false; static String _userName = 'maick_app'; // identidade do usuário neste dispositivo static bool specialMode = false; // modo necessidades especiais // Carrega sessão persistida (token + conversa) do armazenamento local static Future loadSession() async { try { final prefs = await SharedPreferences.getInstance(); _token = prefs.getString('maick_token'); _convId = prefs.getInt('maick_conv_id'); _userName = prefs.getString('user_name') ?? 'maick_app'; } catch (_) {} } // Retorna o nome do usuário identificado neste dispositivo static String get userName => _userName; static String? get token => _token; /// URL WebSocket: wss://iaelwnet.duckdns.org/ws?token=... static String? get wsUrl { if (_token == null) return null; final base = MaickConfig.apiUrl.replaceFirst('https://', 'wss://').replaceFirst('http://', 'ws://'); return '$base/ws?token=$_token'; } // Login com nome específico (chamado pelo onboarding) static Future loginAs(String name) async { _userName = name.toLowerCase(); _token = null; _convId = null; _loggingIn = false; return _ensureToken(); } // Auto-login com secret da família — sem precisar do portal static Future _ensureToken() async { if (_token != null) return true; if (_loggingIn) return false; _loggingIn = true; try { final r = await http.post( Uri.parse('${MaickConfig.apiUrl}/auth/sso'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'secret': 'elwnet_sso_2026_loanda', 'username': _userName, }), ).timeout(const Duration(seconds: 10)); if (r.statusCode == 200) { _token = jsonDecode(r.body)['token']; final prefs = await SharedPreferences.getInstance(); await prefs.setString('maick_token', _token!); return true; } } catch (_) {} _loggingIn = false; return false; } // Chamado pelo portal SSO (opcional) static void login(String portalToken) { _token = portalToken; } // Criar nova conversa static Future _newConversation() async { try { final r = await http.post( Uri.parse('${MaickConfig.apiUrl}/api/conversations'), headers: _headers(), body: jsonEncode({'title': 'Maick App'}), ).timeout(const Duration(seconds: 10)); if (r.statusCode == 200) { _convId = jsonDecode(r.body)['id']; final prefs = await SharedPreferences.getInstance(); await prefs.setInt('maick_conv_id', _convId!); } } catch (_) {} } // Enviar mensagem e receber resposta em stream static Stream sendMessage(String message) async* { final ok = await _ensureToken(); if (!ok) { yield 'Não consegui conectar ao servidor. Verifique a internet.'; return; } if (_convId == null) await _newConversation(); final systemMsg = ''' Você é o Maick, a capivara assistente da família ELW (Washington/Ton, Elaine e Lorena de 6 anos) em Loanda/PR. Personalidade: carinhoso, bem-humorado, direto. Responde em português brasileiro. Você controla a casa inteligente (luzes, temperatura, TV, etc). Ao responder, SEMPRE termine com um JSON assim (sem markdown, sem formatação): {"mood":"calm","energy":0.3,"eyebrow":0.0,"actions":[]} Moods: calm, happy, worried, nervous, tired, lorena, thinking Energy: 0.0 (quieto) a 1.0 (muito animado) Eyebrow: -1.0 a 1.0 (sobrancelha erguida = surpreso/nervoso) Actions: lista de scripts HA (ex: ["script.maick_boa_noite"]) Se detectar texto infantil (Lorena), use mood "lorena" e seja super animado e fofo. '''; final body = jsonEncode({ 'messages': [ {'role': 'system', 'content': systemMsg}, {'role': 'user', 'content': message}, ], 'model': 'auto', 'conversation_id': _convId, 'web_search': false, 'special_mode': specialMode, }); try { final request = http.Request( 'POST', Uri.parse('${MaickConfig.apiUrl}/api/chat'), ); request.headers.addAll(_headers()); request.body = body; final response = await http.Client() .send(request) .timeout(const Duration(seconds: 30)); if (response.statusCode == 401) { // Token expirado — forçar novo login _token = null; _loggingIn = false; yield 'Sessão expirada, reconectando...'; return; } await for (final chunk in response.stream.transform(utf8.decoder)) { for (final rawLine in chunk.split('\n')) { final line = rawLine.trim(); // remove \r e espaços if (line.startsWith('data: ')) { try { final data = jsonDecode(line.substring(6)); if (data['token'] != null) { yield data['token'] as String; } else if (data['error'] != null) { final msg = data['msg'] as String? ?? 'Erro no servidor'; yield 'Hmm, $msg {"mood":"worried","energy":0.3,"eyebrow":0.3,"actions":[]}'; } } catch (_) {} } } } } catch (e) { yield 'Ops, problema de conexão. Tente de novo em instantes. {"mood":"worried","energy":0.2,"eyebrow":0.0,"actions":[]}'; } } // Transcrição de áudio via Groq Whisper no CT120 static Future sttFromBytes(Uint8List audioBytes) async { final ok = await _ensureToken(); if (!ok) return null; try { // usa http.post simples com bytes no body final r = await http.post( Uri.parse('${MaickConfig.apiUrl}/api/stt'), headers: { 'Authorization': 'Bearer $_token', 'Content-Type': 'audio/mp4', }, body: audioBytes, ).timeout(const Duration(seconds: 30)); if (r.statusCode == 200) { return jsonDecode(r.body)['text'] as String?; } // retorna código HTTP como mensagem de erro para diagnóstico return 'HTTP${r.statusCode}'; } catch (e) { // retorna erro como string para diagnóstico (home_screen vai exibir) final msg = e.toString().replaceAll(RegExp(r'[\r\n]'), ' '); return 'ERR:${msg.substring(0, msg.length.clamp(0, 60))}'; } } // Busca áudio TTS do ElevenLabs via CT120 static Future fetchTts(String text, String mood) async { final ok = await _ensureToken(); if (!ok) return null; try { final r = await http.post( Uri.parse('${MaickConfig.apiUrl}/api/tts'), headers: _headers(), body: jsonEncode({'text': text, 'mood': mood}), ).timeout(const Duration(seconds: 12)); if (r.statusCode == 200) return r.bodyBytes; } catch (_) {} return null; } // Busca notificacoes nao lidas para este usuario static Future>> getNotifications() async { final ok = await _ensureToken(); if (!ok) return []; try { final r = await http.get( Uri.parse('${MaickConfig.apiUrl}/api/notifications'), headers: {'Authorization': 'Bearer $_token'}, ).timeout(const Duration(seconds: 10)); if (r.statusCode == 200) { final data = jsonDecode(r.body); return List>.from(data['notifications'] ?? []); } } catch (_) {} return []; } static Map _headers() => { 'Authorization': 'Bearer $_token', 'Content-Type': 'application/json', }; static MaickResponse parseResponse(String fullText) { Mood mood = Mood.calm; double energy = 0.3; double eyeBrow = 0.0; List actions = []; String cleanText = fullText; try { // Encontra o último JSON balanceado com "mood" final result = _extractMoodJson(fullText); if (result != null) { final meta = result['meta'] as Map; final start = result['start'] as int; final end = result['end'] as int; mood = MoodExtension.fromString(meta['mood'] ?? 'calm'); energy = (meta['energy'] ?? 0.3).toDouble(); eyeBrow = (meta['eyebrow'] ?? 0.0).toDouble(); // Limpa o texto ANTES de extrair actions (evita mostrar JSON se actions falhar) cleanText = (fullText.substring(0, start) + fullText.substring(end)).trim(); // Aceita actions como strings "domain.method:entity_id" ou como objetos {service, entity_id} final rawActions = (meta['actions'] ?? []) as List; actions = rawActions.map((a) { if (a is String) return a; if (a is Map) { final s = (a['service'] ?? '') as String; final e = (a['entity_id'] ?? '') as String; return (s.isNotEmpty && e.isNotEmpty) ? '\$s:\$e' : ''; } return ''; }).where((s) => s.isNotEmpty).toList(); } } catch (_) {} return MaickResponse( text: cleanText, mood: mood, energy: energy, eyeBrow: eyeBrow, actions: actions); } // Extrai o último bloco JSON { "mood": ... } com suporte a {} aninhados static Map? _extractMoodJson(String text) { for (int i = text.length - 1; i >= 0; i--) { if (text[i] != '}') continue; int depth = 0; for (int j = i; j >= 0; j--) { if (text[j] == '}') depth++; else if (text[j] == '{') depth--; if (depth == 0) { try { final candidate = text.substring(j, i + 1); final parsed = jsonDecode(candidate) as Map; if (parsed.containsKey('mood')) { return {'meta': parsed, 'start': j, 'end': i + 1}; } } catch (_) {} break; } } } return null; } /// Envia nota de voz (bytes m4a) para outro usuário via CT120 static Future sendVoiceNote(Uint8List audioBytes, String toUser) async { await _ensureToken(); if (_token == null) return false; try { final uri = Uri.parse('\${MaickConfig.apiUrl}/api/voice_relay'); final req = http.MultipartRequest('POST', uri) ..headers['Authorization'] = 'Bearer \$_token' ..fields['to'] = toUser.toLowerCase() ..files.add(http.MultipartFile.fromBytes( 'audio', audioBytes, filename: 'voice.m4a')); final resp = await req.send().timeout(const Duration(seconds: 30)); return resp.statusCode == 200; } catch (_) { return false; } } /// Baixa áudio de uma nota de voz por audio_id static Future fetchVoiceNote(String audioId) async { await _ensureToken(); if (_token == null) return null; try { final r = await http.get( Uri.parse('\${MaickConfig.apiUrl}/api/voice_note/\$audioId'), headers: {'Authorization': 'Bearer \$_token'}, ).timeout(const Duration(seconds: 15)); if (r.statusCode == 200) return r.bodyBytes; } catch (_) {} return null; } // ── PIN Auth ───────────────────────────────────────────────────────────── /// Verifica se usuário existe e se tem PIN configurado static Future> checkUser(String name) async { try { final r = await http.get( Uri.parse('${MaickConfig.apiUrl}/auth/check_user?username=${name.toLowerCase()}'), ).timeout(const Duration(seconds: 8)); if (r.statusCode == 200) { final d = jsonDecode(r.body); return {'exists': d['exists'] ?? false, 'has_pin': d['has_pin'] ?? false}; } } catch (_) {} return {'exists': false, 'has_pin': false}; } /// Login com PIN — retorna true se autenticado com sucesso static Future loginWithPin(String name, String pin) async { try { final r = await http.post( Uri.parse('${MaickConfig.apiUrl}/auth/login'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({'username': name.toLowerCase(), 'pin': pin}), ).timeout(const Duration(seconds: 10)); if (r.statusCode == 200) { final d = jsonDecode(r.body); _token = d['token']; _userName = name.toLowerCase(); _convId = null; final prefs = await SharedPreferences.getInstance(); await prefs.setString('maick_token', _token!); await prefs.setString('user_name', _userName); await prefs.remove('maick_conv_id'); return true; } } catch (_) {} return false; } /// Define PIN para o usuário atual (precisa estar logado via SSO primeiro) static Future setPin(String pin) async { if (_token == null) return false; try { final r = await http.post( Uri.parse('${MaickConfig.apiUrl}/auth/set_pin'), headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer $_token', }, body: jsonEncode({'pin': pin}), ).timeout(const Duration(seconds: 10)); return r.statusCode == 200; } catch (_) {} return false; } /// Logout — limpa sessão local static Future logout() async { _token = null; _convId = null; _userName = 'maick_app'; final prefs = await SharedPreferences.getInstance(); await prefs.remove('maick_token'); await prefs.remove('maick_conv_id'); await prefs.remove('user_name'); } // ── Lembretes ────────────────────────────────────────────────────────────── static Future createReminder(String text, String dueAt) async { if (_token == null) return false; try { final r = await http.post( Uri.parse('${MaickConfig.apiUrl}/api/reminder'), headers: {'Authorization': 'Bearer $_token', 'Content-Type': 'application/json'}, body: jsonEncode({'text': text, 'due_at': dueAt}), ); return r.statusCode == 200; } catch (_) { return false; } } static Future>> getReminders() async { if (_token == null) return []; try { final r = await http.get( Uri.parse('${MaickConfig.apiUrl}/api/reminders'), headers: {'Authorization': 'Bearer $_token'}, ); if (r.statusCode == 200) { final data = jsonDecode(r.body); return List>.from(data['reminders'] ?? []); } } catch (_) {} return []; } // ── Lista de Compras ─────────────────────────────────────────────────────── static Future addShoppingItem(String item) async { if (_token == null || item.isEmpty) return false; try { final r = await http.post( Uri.parse('${MaickConfig.apiUrl}/api/shopping'), headers: {'Authorization': 'Bearer $_token', 'Content-Type': 'application/json'}, body: jsonEncode({'item': item}), ); return r.statusCode == 200; } catch (_) { return false; } } static Future removeShoppingItem(String item) async { if (_token == null || item.isEmpty) return false; try { final r = await http.delete( Uri.parse('${MaickConfig.apiUrl}/api/shopping/${Uri.encodeComponent(item)}'), headers: {'Authorization': 'Bearer $_token'}, ); return r.statusCode == 200; } catch (_) { return false; } } static Future>> getShoppingList() async { if (_token == null) return []; try { final r = await http.get( Uri.parse('${MaickConfig.apiUrl}/api/shopping'), headers: {'Authorization': 'Bearer $_token'}, ); if (r.statusCode == 200) { final data = jsonDecode(r.body); return List>.from(data['items'] ?? []); } } catch (_) {} return []; } // ── Memória da Família ───────────────────────────────────────────────────── static Future setMemory(String key, String value) async { if (_token == null) return false; try { final r = await http.post( Uri.parse('${MaickConfig.apiUrl}/api/memory'), headers: {'Authorization': 'Bearer $_token', 'Content-Type': 'application/json'}, body: jsonEncode({'key': key, 'value': value}), ); return r.statusCode == 200; } catch (_) { return false; } } // ── Briefing ─────────────────────────────────────────────────────────────── static Future getBriefing() async { if (_token == null) return null; try { final r = await http.get( Uri.parse('${MaickConfig.apiUrl}/api/briefing'), headers: {'Authorization': 'Bearer $_token'}, ); if (r.statusCode == 200) { return jsonDecode(r.body)['briefing'] as String?; } } catch (_) {} return null; } } extension MoodExtension on Mood { static Mood fromString(String s) { switch (s.toLowerCase()) { case 'happy': return Mood.happy; case 'worried': return Mood.worried; case 'nervous': return Mood.nervous; case 'tired': return Mood.tired; case 'lorena': return Mood.lorena; case 'thinking': return Mood.thinking; default: return Mood.calm; } } }