From 9b4c2f7e047d3a50fc2df96a67f9282260687230 Mon Sep 17 00:00:00 2001 From: ruben <240417@epvc.pt> Date: Sun, 17 May 2026 17:08:18 +0100 Subject: [PATCH] Camara e chat IA --- lib/Screens/ai_chat_screen.dart | 258 ++++++++++++++++++++ lib/services/ai_recommendation_service.dart | 128 ++++++++++ 2 files changed, 386 insertions(+) create mode 100644 lib/Screens/ai_chat_screen.dart create mode 100644 lib/services/ai_recommendation_service.dart diff --git a/lib/Screens/ai_chat_screen.dart b/lib/Screens/ai_chat_screen.dart new file mode 100644 index 0000000..97f1ae3 --- /dev/null +++ b/lib/Screens/ai_chat_screen.dart @@ -0,0 +1,258 @@ +import 'package:flutter/material.dart'; +import '../services/ai_recommendation_service.dart'; + +class AiChatScreen extends StatefulWidget { + const AiChatScreen({super.key}); + + @override + State createState() => _AiChatScreenState(); +} + +class _AiChatScreenState extends State { + final TextEditingController _controller = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + final AiRecommendationService _service = AiRecommendationService(); + final List<_ChatMessage> _messages = [ + const _ChatMessage( + text: + 'Olá! Diz-me uma ocasião, destino ou plano e eu recomendo o que deves levar. Ex: "uma viagem para Itália de 5 dias".', + isUser: false, + ), + ]; + bool _isLoading = false; + + Future _sendMessage([String? suggestion]) async { + final text = (suggestion ?? _controller.text).trim(); + if (text.isEmpty || _isLoading) return; + + setState(() { + _messages.add(_ChatMessage(text: text, isUser: true)); + _isLoading = true; + }); + _controller.clear(); + _scrollToBottom(); + + try { + final response = await _service.recommendForOccasion(text); + if (!mounted) return; + setState(() { + _messages.add(_ChatMessage(text: response, isUser: false)); + }); + } catch (e) { + if (!mounted) return; + setState(() { + _messages.add( + _ChatMessage( + text: 'Não consegui gerar uma recomendação agora. Tenta novamente.', + isUser: false, + ), + ); + }); + } finally { + if (mounted) { + setState(() => _isLoading = false); + _scrollToBottom(); + } + } + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_scrollController.hasClients) return; + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + }); + } + + @override + void dispose() { + _controller.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFFFE5CC), + appBar: AppBar( + backgroundColor: const Color(0xFF0066CC), + elevation: 0, + title: const Text( + 'DayMaker IA', + style: TextStyle(color: Colors.white, fontSize: 20), + ), + centerTitle: true, + ), + body: SafeArea( + child: Column( + children: [ + _buildSuggestions(), + Expanded( + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + itemCount: _messages.length + (_isLoading ? 1 : 0), + itemBuilder: (context, index) { + if (_isLoading && index == _messages.length) { + return const _TypingBubble(); + } + return _MessageBubble(message: _messages[index]); + }, + ), + ), + _buildInput(), + ], + ), + ), + ); + } + + Widget _buildSuggestions() { + final suggestions = [ + 'Viagem para Itália', + 'Fim de semana na praia', + 'Reunião de trabalho', + ]; + + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + color: const Color(0xFFFFE5CC), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: suggestions.map((suggestion) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ActionChip( + backgroundColor: Colors.white, + side: const BorderSide(color: Color(0xFFE0E0E0)), + label: Text(suggestion), + onPressed: () => _sendMessage(suggestion), + ), + ); + }).toList(), + ), + ), + ); + } + + Widget _buildInput() { + return Container( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + decoration: const BoxDecoration(color: Colors.white), + child: Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(24), + ), + child: TextField( + controller: _controller, + minLines: 1, + maxLines: 4, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _sendMessage(), + decoration: const InputDecoration( + hintText: 'Ex: viagem para Itália de 5 dias...', + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 48, + height: 48, + child: ElevatedButton( + onPressed: _isLoading ? null : () => _sendMessage(), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0066CC), + shape: const CircleBorder(), + padding: EdgeInsets.zero, + ), + child: const Icon(Icons.send, color: Colors.white), + ), + ), + ], + ), + ); + } +} + +class _ChatMessage { + final String text; + final bool isUser; + + const _ChatMessage({required this.text, required this.isUser}); +} + +class _MessageBubble extends StatelessWidget { + final _ChatMessage message; + + const _MessageBubble({required this.message}); + + @override + Widget build(BuildContext context) { + final alignment = message.isUser + ? Alignment.centerRight + : Alignment.centerLeft; + final backgroundColor = message.isUser + ? const Color(0xFF0066CC) + : Colors.white; + final textColor = message.isUser ? Colors.white : const Color(0xFF333333); + + return Align( + alignment: alignment, + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.78, + ), + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(18), + ), + child: Text( + message.text, + style: TextStyle(color: textColor, fontSize: 15, height: 1.35), + ), + ), + ); + } +} + +class _TypingBubble extends StatelessWidget { + const _TypingBubble(); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + ), + child: const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } +} diff --git a/lib/services/ai_recommendation_service.dart b/lib/services/ai_recommendation_service.dart new file mode 100644 index 0000000..4389cd4 --- /dev/null +++ b/lib/services/ai_recommendation_service.dart @@ -0,0 +1,128 @@ +class AiRecommendationService { + Future recommendForOccasion(String prompt) async { + final input = prompt.trim(); + if (input.isEmpty) { + return 'Diz-me a ocasião ou destino e eu ajudo-te a preparar uma lista. Por exemplo: "uma viagem para Itália de 5 dias".'; + } + + await Future.delayed(const Duration(milliseconds: 600)); + + final lower = input.toLowerCase(); + final items = {}; + final tips = []; + + items.addAll([ + 'Documento de identificação ou passaporte', + 'Carteira/cartões e algum dinheiro', + 'Telemóvel e carregador', + 'Power bank', + 'Produtos de higiene pessoal', + 'Roupa interior e meias suficientes', + 'Medicamentos pessoais', + ]); + + if (_containsAny(lower, [ + 'viagem', + 'viajar', + 'italia', + 'itália', + 'paris', + 'espanha', + 'frança', + 'fim de semana', + ])) { + items.addAll([ + 'Adaptador de tomada se necessário', + 'Mochila pequena para passeios', + 'Garrafa de água reutilizável', + 'Cópia digital dos documentos', + 'Seguro/cartão europeu de saúde se aplicável', + ]); + tips.add('Confirma o clima e as regras de bagagem antes de sair.'); + } + + if (_containsAny(lower, [ + 'italia', + 'itália', + 'roma', + 'milão', + 'veneza', + 'florença', + ])) { + items.addAll([ + 'Calçado muito confortável para caminhar', + 'Óculos de sol', + 'Roupa leve e versátil', + 'Casaco leve para a noite', + 'Roupa mais composta para igrejas ou locais religiosos', + ]); + tips.add( + 'Em Itália vais provavelmente caminhar bastante; prioriza conforto no calçado.', + ); + tips.add( + 'Para visitar igrejas, é útil levar roupa que cubra ombros/joelhos.', + ); + } + + if (_containsAny(lower, ['praia', 'piscina', 'verão', 'calor', 'quente'])) { + items.addAll([ + 'Protetor solar', + 'Fato de banho', + 'Toalha de praia', + 'Chinelos', + 'Chapéu ou boné', + ]); + } + + if (_containsAny(lower, ['frio', 'inverno', 'neve', 'montanha'])) { + items.addAll([ + 'Casaco quente', + 'Cachecol', + 'Luvas', + 'Camisolas térmicas', + 'Calçado impermeável', + ]); + } + + if (_containsAny(lower, [ + 'trabalho', + 'reunião', + 'conferência', + 'evento profissional', + ])) { + items.addAll([ + 'Portátil e carregador', + 'Roupa formal ou smart casual', + 'Bloco de notas', + 'Caneta', + 'Cartões/documentos profissionais', + ]); + } + + if (_containsAny(lower, [ + 'casamento', + 'cerimónia', + 'formal', + 'jantar elegante', + ])) { + items.addAll([ + 'Roupa formal', + 'Sapatos formais', + 'Acessórios', + 'Perfume', + 'Kit pequeno de emergência para roupa', + ]); + } + + final itemLines = items.take(18).map((item) => '- $item').join('\n'); + final tipLines = tips.isEmpty + ? '' + : '\n\nDicas:\n${tips.map((tip) => '- $tip').join('\n')}'; + + return 'Para $input, eu levaria:\n\n$itemLines$tipLines\n\nSe me disseres duração, clima e tipo de viagem, consigo ajustar melhor a lista.'; + } + + bool _containsAny(String text, List terms) { + return terms.any(text.contains); + } +}