IA Funcinonal

This commit is contained in:
Carlos Correia
2026-05-21 11:53:35 +01:00
parent 9999011cfd
commit 967584f083
10 changed files with 482 additions and 382 deletions

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../services/ai_recommendation_service.dart';
import '../theme/app_theme.dart';
class AiChatScreen extends StatefulWidget {
const AiChatScreen({super.key});
@@ -21,19 +22,25 @@ class _AiChatScreenState extends State<AiChatScreen> {
];
bool _isLoading = false;
Future<void> _sendMessage([String? suggestion]) async {
Future<void> _sendMessage({
String? suggestion,
bool silent = false,
bool hideUserMessage = false,
}) async {
final text = (suggestion ?? _controller.text).trim();
if (text.isEmpty || _isLoading) return;
setState(() {
_messages.add(_ChatMessage(text: text, isUser: true));
if (!hideUserMessage) {
_messages.add(_ChatMessage(text: text, isUser: true));
}
_isLoading = true;
});
_controller.clear();
_scrollToBottom();
try {
final response = await _service.recommendForOccasion(text);
final response = await _service.sendMessage(text, silent: silent);
if (!mounted) return;
setState(() {
_messages.add(_ChatMessage(text: response, isUser: false));
@@ -43,7 +50,7 @@ class _AiChatScreenState extends State<AiChatScreen> {
setState(() {
_messages.add(
_ChatMessage(
text: 'Não consegui gerar uma recomendação agora. Tenta novamente.',
text: 'Nao consegui gerar uma recomendacao agora. Tenta novamente.',
isUser: false,
),
);
@@ -77,24 +84,16 @@ class _AiChatScreenState extends State<AiChatScreen> {
@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,
),
backgroundColor: AppColors.background,
body: SafeArea(
child: Column(
children: [
_buildHeader(),
_buildSuggestions(),
Expanded(
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
itemCount: _messages.length + (_isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (_isLoading && index == _messages.length) {
@@ -111,6 +110,54 @@ class _AiChatScreenState extends State<AiChatScreen> {
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
decoration: BoxDecoration(
gradient: AppColors.brandGradient,
boxShadow: AppShadows.brand,
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.22),
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: const Icon(
Icons.auto_awesome_rounded,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'DayMaker IA',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 2),
Text(
'Pergunta-me sobre o teu dia ou viagem',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
),
],
),
);
}
Widget _buildSuggestions() {
final suggestions = [
'Viagem para Itália',
@@ -120,19 +167,18 @@ class _AiChatScreenState extends State<AiChatScreen> {
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
color: const Color(0xFFFFE5CC),
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
color: AppColors.background,
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),
child: AppChip(
label: suggestion,
icon: Icons.bolt_rounded,
onTap: () => _sendMessage(suggestion: suggestion),
),
);
}).toList(),
@@ -143,15 +189,25 @@ class _AiChatScreenState extends State<AiChatScreen> {
Widget _buildInput() {
return Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
decoration: const BoxDecoration(color: Colors.white),
padding: const EdgeInsets.fromLTRB(12, 10, 12, 14),
decoration: BoxDecoration(
color: AppColors.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 16,
offset: const Offset(0, -4),
),
],
),
child: Row(
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(24),
color: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(AppRadius.pill),
border: Border.all(color: AppColors.border),
),
child: TextField(
controller: _controller,
@@ -159,8 +215,10 @@ class _AiChatScreenState extends State<AiChatScreen> {
maxLines: 4,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
style: AppText.body,
decoration: const InputDecoration(
hintText: 'Ex: viagem para Itália de 5 dias...',
hintText: 'Escreve uma mensagem...',
hintStyle: TextStyle(color: AppColors.textTertiary),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
@@ -170,18 +228,27 @@ class _AiChatScreenState extends State<AiChatScreen> {
),
),
),
const SizedBox(width: 8),
SizedBox(
const SizedBox(width: 10),
Container(
width: 48,
height: 48,
child: ElevatedButton(
onPressed: _isLoading ? null : () => _sendMessage(),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0066CC),
shape: const CircleBorder(),
padding: EdgeInsets.zero,
decoration: BoxDecoration(
gradient: AppColors.brandGradient,
shape: BoxShape.circle,
boxShadow: AppShadows.brand,
),
child: Material(
color: Colors.transparent,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: _isLoading ? null : () => _sendMessage(),
child: const Icon(
Icons.send_rounded,
color: Colors.white,
size: 22,
),
),
child: const Icon(Icons.send, color: Colors.white),
),
),
],
@@ -204,13 +271,8 @@ class _MessageBubble extends StatelessWidget {
@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);
final isUser = message.isUser;
final alignment = isUser ? Alignment.centerRight : Alignment.centerLeft;
return Align(
alignment: alignment,
@@ -219,14 +281,25 @@ class _MessageBubble extends StatelessWidget {
maxWidth: MediaQuery.of(context).size.width * 0.78,
),
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(18),
gradient: isUser ? AppColors.brandGradient : null,
color: isUser ? null : AppColors.surface,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(AppRadius.lg),
topRight: const Radius.circular(AppRadius.lg),
bottomLeft: Radius.circular(isUser ? AppRadius.lg : 4),
bottomRight: Radius.circular(isUser ? 4 : AppRadius.lg),
),
boxShadow: isUser ? AppShadows.brand : AppShadows.soft,
),
child: Text(
message.text,
style: TextStyle(color: textColor, fontSize: 15, height: 1.35),
style: TextStyle(
color: isUser ? Colors.white : AppColors.textPrimary,
fontSize: 15,
height: 1.4,
),
),
),
);
@@ -242,15 +315,24 @@ class _TypingBubble extends StatelessWidget {
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
color: AppColors.surface,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(AppRadius.lg),
topRight: Radius.circular(AppRadius.lg),
bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(AppRadius.lg),
),
boxShadow: AppShadows.soft,
),
child: const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.2,
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primary),
),
),
),
);

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../constants/item_categories.dart';
import '../services/ai_recommendation_service.dart';
import '../theme/app_theme.dart';
import 'add_item_screen.dart';
import 'ai_chat_screen.dart';
import 'item_screen.dart';
import 'perfil_screen.dart';
import 'week_screen.dart';
@@ -21,6 +23,7 @@ class _HomeScreenState extends State<HomeScreen> {
_HomeContent(),
ItemScreen(),
WeekScreen(),
AiChatScreen(),
PerfilScreen(),
];
@@ -55,7 +58,8 @@ class _HomeScreenState extends State<HomeScreen> {
_navItem(0, Icons.home_rounded, 'Início'),
_navItem(1, Icons.inventory_2_rounded, 'Itens'),
_navItem(2, Icons.calendar_month_rounded, 'Semana'),
_navItem(3, Icons.person_rounded, 'Perfil'),
_navItem(3, Icons.auto_awesome_rounded, 'IA'),
_navItem(4, Icons.person_rounded, 'Perfil'),
],
),
),
@@ -87,16 +91,14 @@ class _HomeScreenState extends State<HomeScreen> {
Icon(
icon,
size: 24,
color:
selected ? AppColors.primary : AppColors.textSecondary,
color: selected ? AppColors.primary : AppColors.textSecondary,
),
const SizedBox(height: 3),
Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight:
selected ? FontWeight.w700 : FontWeight.w500,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected
? AppColors.primary
: AppColors.textSecondary,
@@ -198,9 +200,8 @@ class _HomeContentState extends State<_HomeContent> {
if (!mounted) return;
setState(() {
_itemCount = all.length;
_userName = userRow?['nome'] ??
user.email?.split('@').first ??
'Utilizador';
_userName =
userRow?['nome'] ?? user.email?.split('@').first ?? 'Utilizador';
_recentItems = List<Map<String, dynamic>>.from(recent);
_todayItems = todayItems;
_isLoading = false;
@@ -227,6 +228,8 @@ class _HomeContentState extends State<_HomeContent> {
_buildGreeting(),
const SizedBox(height: 20),
_buildHeroCard(),
const SizedBox(height: 16),
_buildAiSuggestionButton(),
const SizedBox(height: 24),
_buildSectionHeader('Hoje'),
const SizedBox(height: 12),
@@ -249,23 +252,17 @@ class _HomeContentState extends State<_HomeContent> {
final saudacao = hour < 12
? 'Bom dia'
: hour < 19
? 'Boa tarde'
: 'Boa noite';
? 'Boa tarde'
: 'Boa noite';
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
saudacao,
style: AppText.bodySecondary,
),
Text(saudacao, style: AppText.bodySecondary),
const SizedBox(height: 2),
Text(
_userName.isEmpty ? 'Olá!' : _userName,
style: AppText.h2,
),
Text(_userName.isEmpty ? 'Olá!' : _userName, style: AppText.h2),
],
),
),
@@ -413,10 +410,7 @@ class _HomeContentState extends State<_HomeContent> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Nada planeado para hoje',
style: AppText.body,
),
Text('Nada planeado para hoje', style: AppText.body),
SizedBox(height: 2),
Text(
'Vá à aba Semana para organizar',
@@ -454,17 +448,11 @@ class _HomeContentState extends State<_HomeContent> {
color: AppColors.accent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: const Icon(
Icons.add_box_rounded,
color: AppColors.accent,
),
child: const Icon(Icons.add_box_rounded, color: AppColors.accent),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Adicione o seu primeiro item',
style: AppText.body,
),
child: Text('Adicione o seu primeiro item', style: AppText.body),
),
],
),
@@ -509,20 +497,12 @@ class _HomeContentState extends State<_HomeContent> {
? Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Center(
child: Icon(
cat.icon,
color: cat.color,
size: 32,
),
errorBuilder: (_, _, _) => Center(
child: Icon(cat.icon, color: cat.color, size: 32),
),
)
: Center(
child: Icon(
cat.icon,
color: cat.color,
size: 32,
),
child: Icon(cat.icon, color: cat.color, size: 32),
),
),
],
@@ -569,7 +549,7 @@ class _HomeContentState extends State<_HomeContent> {
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 3,
itemBuilder: (_, __) => Container(
itemBuilder: (_, _) => Container(
width: 110,
margin: const EdgeInsets.only(right: 12),
decoration: BoxDecoration(
@@ -581,6 +561,127 @@ class _HomeContentState extends State<_HomeContent> {
);
}
Widget _buildAiSuggestionButton() {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(AppRadius.lg),
onTap: () async {
final service = AiRecommendationService();
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
final response = await service.sendMessage(
'vou fazer uma viagem de 4 horas de onibus',
silent: true,
);
if (!mounted) return;
Navigator.of(context).pop();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.6,
),
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.xl),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.auto_awesome, color: AppColors.primary),
const SizedBox(width: 10),
const Expanded(
child: Text('Sugestao da IA', style: AppText.h3),
),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close_rounded),
),
],
),
const Divider(height: 20),
Flexible(
child: SingleChildScrollView(
child: Text(response, style: AppText.body),
),
),
],
),
),
);
},
child: Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: AppColors.warmGradient,
borderRadius: BorderRadius.circular(AppRadius.lg),
boxShadow: [
BoxShadow(
color: AppColors.accent.withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: const Icon(
Icons.auto_awesome,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 14),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pedir sugestao a IA',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
SizedBox(height: 2),
Text(
'Monta um outfit para o teu dia',
style: TextStyle(fontSize: 12, color: Colors.white70),
),
],
),
),
const Icon(
Icons.arrow_forward_ios_rounded,
size: 16,
color: Colors.white70,
),
],
),
),
),
);
}
Widget _buildAddCta() {
return Material(
color: Colors.transparent,
@@ -588,9 +689,7 @@ class _HomeContentState extends State<_HomeContent> {
borderRadius: BorderRadius.circular(AppRadius.lg),
onTap: () {
Navigator.of(context)
.push(
MaterialPageRoute(builder: (_) => const AddItemScreen()),
)
.push(MaterialPageRoute(builder: (_) => const AddItemScreen()))
.then((_) => _loadData());
},
child: Container(
@@ -613,8 +712,11 @@ class _HomeContentState extends State<_HomeContent> {
gradient: AppColors.brandGradient,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child:
const Icon(Icons.add_rounded, color: Colors.white, size: 24),
child: const Icon(
Icons.add_rounded,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 14),
const Expanded(

View File

@@ -21,12 +21,15 @@ class _ItemScreenState extends State<ItemScreen> {
List<Map<String, dynamic>> get _filteredItems {
return _items.where((item) {
final name = (item['nome'] ?? '').toString().toLowerCase();
final tags =
List<String>.from(item['tags'] ?? []).join(' ').toLowerCase();
final matchesSearch = _searchQuery.isEmpty ||
final tags = List<String>.from(
item['tags'] ?? [],
).join(' ').toLowerCase();
final matchesSearch =
_searchQuery.isEmpty ||
name.contains(_searchQuery.toLowerCase()) ||
tags.contains(_searchQuery.toLowerCase());
final matchesCategory = _selectedCategoryFilter == null ||
final matchesCategory =
_selectedCategoryFilter == null ||
item['categoria'] == _selectedCategoryFilter;
return matchesSearch && matchesCategory;
}).toList();
@@ -110,8 +113,7 @@ class _ItemScreenState extends State<ItemScreen> {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
ItemDetailScreen(item: item, imageUrl: _imageUrl(item)),
builder: (_) => ItemDetailScreen(item: item, imageUrl: _imageUrl(item)),
),
).then((_) => _loadItems());
}
@@ -140,16 +142,14 @@ class _ItemScreenState extends State<ItemScreen> {
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _items.isEmpty
? _buildEmpty()
: _filteredItems.isEmpty
? _buildNoResults()
: RefreshIndicator(
onRefresh: _loadItems,
color: AppColors.primary,
child: _gridView
? _buildGrid()
: _buildList(),
),
? _buildEmpty()
: _filteredItems.isEmpty
? _buildNoResults()
: RefreshIndicator(
onRefresh: _loadItems,
color: AppColors.primary,
child: _gridView ? _buildGrid() : _buildList(),
),
),
],
),
@@ -211,8 +211,7 @@ class _ItemScreenState extends State<ItemScreen> {
child: Icon(
icon,
size: 18,
color:
selected ? AppColors.primary : AppColors.textSecondary,
color: selected ? AppColors.primary : AppColors.textSecondary,
),
),
),
@@ -266,8 +265,7 @@ class _ItemScreenState extends State<ItemScreen> {
icon: c.icon,
color: c.color,
selected: _selectedCategoryFilter == c.id,
onTap: () =>
setState(() => _selectedCategoryFilter = c.id),
onTap: () => setState(() => _selectedCategoryFilter = c.id),
),
),
),
@@ -327,7 +325,7 @@ class _ItemScreenState extends State<ItemScreen> {
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.78,
childAspectRatio: 0.50,
),
itemCount: _filteredItems.length,
itemBuilder: (_, i) => _buildGridCard(_filteredItems[i]),
@@ -368,18 +366,13 @@ class _ItemScreenState extends State<ItemScreen> {
Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Center(child: Icon(cat.icon, color: cat.color, size: 40)),
errorBuilder: (_, __, ___) => Center(
child: Icon(cat.icon, color: cat.color, size: 40),
),
)
else
Center(
child: Icon(cat.icon, color: cat.color, size: 40),
),
Positioned(
top: 8,
right: 8,
child: _moreButton(item),
),
Center(child: Icon(cat.icon, color: cat.color, size: 40)),
Positioned(top: 8, right: 8, child: _moreButton(item)),
],
),
),
@@ -463,11 +456,8 @@ class _ItemScreenState extends State<ItemScreen> {
? Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Icon(
cat.icon,
color: cat.color,
size: 28,
),
errorBuilder: (_, __, ___) =>
Icon(cat.icon, color: cat.color, size: 28),
)
: Icon(cat.icon, color: cat.color, size: 28),
),
@@ -525,7 +515,7 @@ class _ItemScreenState extends State<ItemScreen> {
Widget _moreButton(Map<String, dynamic> item) {
return Material(
color: Colors.white.withValues(alpha: 0.85),
color: Colors.white.withValues(alpha: 0.75),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
@@ -575,15 +565,10 @@ class _ItemScreenState extends State<ItemScreen> {
_openItem(item);
},
),
_actionTile(
Icons.edit_outlined,
'Editar',
AppColors.primary,
() {
Navigator.pop(ctx);
_editItem(item);
},
),
_actionTile(Icons.edit_outlined, 'Editar', AppColors.primary, () {
Navigator.pop(ctx);
_editItem(item);
}),
_actionTile(
Icons.delete_outline_rounded,
'Apagar',
@@ -619,10 +604,7 @@ class _ItemScreenState extends State<ItemScreen> {
),
title: Text(
label,
style: TextStyle(
color: color,
fontWeight: FontWeight.w600,
),
style: TextStyle(color: color, fontWeight: FontWeight.w600),
),
onTap: onTap,
);
@@ -714,13 +696,12 @@ class ItemDetailScreen extends StatelessWidget {
Image.network(
imageUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Center(child: Icon(cat.icon, color: cat.color, size: 80)),
errorBuilder: (_, __, ___) => Center(
child: Icon(cat.icon, color: cat.color, size: 80),
),
)
else
Center(
child: Icon(cat.icon, color: cat.color, size: 80),
),
Center(child: Icon(cat.icon, color: cat.color, size: 80)),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
@@ -786,8 +767,9 @@ class ItemDetailScreen extends StatelessWidget {
),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius:
BorderRadius.circular(AppRadius.pill),
borderRadius: BorderRadius.circular(
AppRadius.pill,
),
border: Border.all(color: AppColors.border),
),
child: Text(
@@ -853,11 +835,14 @@ class _EditItemScreenState extends State<EditItemScreen> {
}
setState(() => _isLoading = true);
try {
await Supabase.instance.client.from('items').update({
'nome': _nameController.text.trim(),
'categoria': _selectedCategory?.id,
'tags': _selectedTags.toList(),
}).eq('id', widget.item['id']);
await Supabase.instance.client
.from('items')
.update({
'nome': _nameController.text.trim(),
'categoria': _selectedCategory?.id,
'tags': _selectedTags.toList(),
})
.eq('id', widget.item['id']);
if (mounted) {
AppSnack.success(context, 'Item atualizado!');
Navigator.pop(context);

View File

@@ -214,7 +214,7 @@ class _PerfilScreenState extends State<PerfilScreen> {
? Image.network(
_avatarUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
errorBuilder: (_, _, _) => Container(
color: AppColors.surface,
child: const Icon(
Icons.person_rounded,

View File

@@ -15,7 +15,15 @@ class _WeekScreenState extends State<WeekScreen> {
List<Map<String, dynamic>> _dayItems = [];
bool _isLoading = false;
static const _weekdayShort = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom'];
static const _weekdayShort = [
'Seg',
'Ter',
'Qua',
'Qui',
'Sex',
'Sáb',
'Dom',
];
static const _weekdayLong = [
'Segunda',
'Terça',
@@ -34,8 +42,11 @@ class _WeekScreenState extends State<WeekScreen> {
DateTime get _startOfWeek {
final now = DateTime.now();
return DateTime(now.year, now.month, now.day)
.subtract(Duration(days: now.weekday - 1));
return DateTime(
now.year,
now.month,
now.day,
).subtract(Duration(days: now.weekday - 1));
}
String _dateKey(DateTime d) =>
@@ -108,8 +119,9 @@ class _WeekScreenState extends State<WeekScreen> {
.eq('user_id', user.id);
final available = List<Map<String, dynamic>>.from(allItems);
final existingIds = _dayItems.map((i) => i['id']).toSet();
final toShow =
available.where((i) => !existingIds.contains(i['id'])).toList();
final toShow = available
.where((i) => !existingIds.contains(i['id']))
.toList();
if (!mounted) return;
final selected = await showModalBottomSheet<List<int>>(
@@ -122,8 +134,9 @@ class _WeekScreenState extends State<WeekScreen> {
try {
final planId = await _getOrCreatePlanId(_selectedDay);
final rows =
selected.map((id) => {'plan_id': planId, 'item_id': id}).toList();
final rows = selected
.map((id) => {'plan_id': planId, 'item_id': id})
.toList();
await Supabase.instance.client.from('plan_items').insert(rows);
_loadDayItems();
} catch (e) {
@@ -163,15 +176,12 @@ class _WeekScreenState extends State<WeekScreen> {
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _dayItems.isEmpty
? _buildEmpty()
: ListView.builder(
padding: const EdgeInsets.fromLTRB(
20, 0, 20, 120,
),
itemCount: _dayItems.length,
itemBuilder: (_, i) =>
_buildItemTile(_dayItems[i]),
),
? _buildEmpty()
: ListView.builder(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 120),
itemCount: _dayItems.length,
itemBuilder: (_, i) => _buildItemTile(_dayItems[i]),
),
),
],
),
@@ -188,10 +198,7 @@ class _WeekScreenState extends State<WeekScreen> {
children: [
Text('Minha Semana', style: AppText.h2),
SizedBox(height: 2),
Text(
'Planeie o que precisa para cada dia',
style: AppText.caption,
),
Text('Planeie o que precisa para cada dia', style: AppText.caption),
],
),
);
@@ -202,11 +209,13 @@ class _WeekScreenState extends State<WeekScreen> {
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: days.map((day) {
final isSelected = day.year == _selectedDay.year &&
final isSelected =
day.year == _selectedDay.year &&
day.month == _selectedDay.month &&
day.day == _selectedDay.day;
final today = DateTime.now();
final isToday = day.year == today.year &&
final isToday =
day.year == today.year &&
day.month == today.month &&
day.day == today.day;
return Expanded(
@@ -322,10 +331,7 @@ class _WeekScreenState extends State<WeekScreen> {
const SizedBox(height: 16),
const Text('Nada planeado', style: AppText.h3),
const SizedBox(height: 4),
Text(
'Toque em + para adicionar itens',
style: AppText.caption,
),
Text('Toque em + para adicionar itens', style: AppText.caption),
],
),
);
@@ -369,7 +375,7 @@ class _WeekScreenState extends State<WeekScreen> {
? Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
errorBuilder: (_, _, _) =>
Icon(cat.icon, color: cat.color, size: 24),
)
: Icon(cat.icon, color: cat.color, size: 24),
@@ -476,10 +482,9 @@ class _ItemPickerSheetState extends State<_ItemPickerSheet> {
Widget build(BuildContext context) {
final filtered = widget.items
.where(
(i) => (i['nome'] ?? '')
.toString()
.toLowerCase()
.contains(_query.toLowerCase()),
(i) => (i['nome'] ?? '').toString().toLowerCase().contains(
_query.toLowerCase(),
),
)
.toList();
@@ -541,8 +546,7 @@ class _ItemPickerSheetState extends State<_ItemPickerSheet> {
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
itemCount: filtered.length,
itemBuilder: (_, i) =>
_buildPickerTile(filtered[i]),
itemBuilder: (_, i) => _buildPickerTile(filtered[i]),
),
),
SafeArea(
@@ -556,8 +560,7 @@ class _ItemPickerSheetState extends State<_ItemPickerSheet> {
icon: Icons.check_rounded,
onPressed: _selected.isEmpty
? null
: () =>
Navigator.pop(context, _selected.toList()),
: () => Navigator.pop(context, _selected.toList()),
),
),
),
@@ -612,7 +615,7 @@ class _ItemPickerSheetState extends State<_ItemPickerSheet> {
? Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
errorBuilder: (_, _, _) =>
Icon(cat.icon, color: cat.color, size: 22),
)
: Icon(cat.icon, color: cat.color, size: 22),
@@ -649,14 +652,10 @@ class _ItemPickerSheetState extends State<_ItemPickerSheet> {
width: 24,
height: 24,
decoration: BoxDecoration(
color: selected
? AppColors.primary
: Colors.transparent,
color: selected ? AppColors.primary : Colors.transparent,
shape: BoxShape.circle,
border: Border.all(
color: selected
? AppColors.primary
: AppColors.border,
color: selected ? AppColors.primary : AppColors.border,
width: 2,
),
),

View File

@@ -1,128 +1,116 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:supabase_flutter/supabase_flutter.dart';
class AiRecommendationService {
Future<String> 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".';
}
static const String _apiUrl = 'https://apichat.epvc.pt/api/chat';
static const String _model = 'llama3.2:3b';
await Future<void>.delayed(const Duration(milliseconds: 600));
static const String _systemPrompt =
'voce é uma agente de ia que tem como objetivo ajudar o utilizador a formar uma especie de outfit e acessorios como consolas e ate documentacao que é preciso para seu dia ou viagem. voce usa uma linguagem descontraida mas sem usar emojis ou afins. para saber oque escolher voce vai usar as tags que estao nos itens ou suas notas. responde sempre em portugues.';
final lower = input.toLowerCase();
final items = <String>{};
final tips = <String>[];
final List<Map<String, String>> _history = [];
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.',
Future<String> _itemsContext() async {
try {
final user = Supabase.instance.client.auth.currentUser;
if (user == null) return '';
final rows = await Supabase.instance.client
.from('items')
.select()
.eq('user_id', user.id);
if (rows.isEmpty) return '';
final buf = StringBuffer(
'Itens disponiveis no inventario do utilizador:\n',
);
for (final it in rows) {
final nome = it['nome'] ?? '';
final cat = it['categoria'] ?? '';
final tags = (it['tags'] as List?)?.join(', ') ?? '';
final nota = it['nota'] ?? it['notes'] ?? '';
buf.write('- $nome');
if (cat.toString().isNotEmpty) buf.write(' (categoria: $cat)');
if (tags.isNotEmpty) buf.write(' [tags: $tags]');
if (nota.toString().isNotEmpty) buf.write(' {nota: $nota}');
buf.writeln();
}
return buf.toString();
} catch (_) {
return '';
}
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<String> terms) {
return terms.any(text.contains);
Future<String> sendMessage(String userMessage, {bool silent = false}) async {
final ctx = await _itemsContext();
final systemContent = ctx.isNotEmpty
? '$_systemPrompt\n\n$ctx'
: _systemPrompt;
final messages = <Map<String, String>>[
{'role': 'system', 'content': systemContent},
..._history,
];
final userContent = silent
? '$userMessage\n\n[Instrucao: nao expliques nem comentes. Devolve apenas a lista de itens (do meu inventario quando possivel) que sugeres para esta ocasiao, em formato de lista simples.]'
: userMessage;
messages.add({'role': 'user', 'content': userContent});
try {
final response = await http
.post(
Uri.parse(_apiUrl),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'model': _model,
'messages': messages,
'stream': false,
}),
)
.timeout(const Duration(seconds: 60));
if (response.statusCode != 200) {
return 'Erro a contactar a IA (${response.statusCode}). Tenta de novo.';
}
final data = jsonDecode(utf8.decode(response.bodyBytes));
final aiText = _extract(data);
if (aiText.isEmpty) {
return 'Nao recebi resposta da IA. Tenta de novo.';
}
// guardar no historico (mensagem do user "limpa", sem instrucoes silenciosas)
_history.add({'role': 'user', 'content': userMessage});
_history.add({'role': 'assistant', 'content': aiText});
return aiText;
} catch (_) {
return 'Nao consegui ligar ao servidor. Verifica a tua internet e tenta de novo.';
}
}
String _extract(dynamic data) {
if (data is Map) {
// Ollama /api/chat -> { message: { role, content }, done, ... }
final msg = data['message'];
if (msg is Map && msg['content'] != null) {
return msg['content'].toString().trim();
}
// Ollama /api/generate -> { response: "..." }
if (data['response'] != null) return data['response'].toString().trim();
// OpenAI-style fallback
final choices = data['choices'];
if (choices is List && choices.isNotEmpty) {
final m = choices[0]['message'];
if (m is Map && m['content'] != null) {
return m['content'].toString().trim();
}
}
if (data['content'] != null) return data['content'].toString().trim();
}
if (data is String) return data.trim();
return '';
}
void clearHistory() => _history.clear();
}