This commit is contained in:
Carlos Correia
2026-05-29 11:03:29 +01:00
parent 967584f083
commit fee538eebd
14 changed files with 1349 additions and 1149 deletions

View File

@@ -566,61 +566,7 @@ class _HomeContentState extends State<_HomeContent> {
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),
),
),
],
),
),
);
},
onTap: () => _requestAiSuggestion(),
child: Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
@@ -682,6 +628,179 @@ class _HomeContentState extends State<_HomeContent> {
);
}
Future<String?> _askOccasion() async {
final controller = TextEditingController();
final suggestions = [
'Piquenique no parque',
'Viagem de 4h de onibus',
'Dia de praia',
'Reuniao de trabalho',
'Jantar fora',
];
return showDialog<String>(
context: context,
builder: (ctx) => Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.xl),
),
backgroundColor: AppColors.surface,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
gradient: AppColors.brandGradient,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: const Icon(
Icons.auto_awesome,
color: Colors.white,
size: 18,
),
),
const SizedBox(width: 10),
const Expanded(
child: Text('Pedir sugestao', style: AppText.h3),
),
],
),
const SizedBox(height: 4),
const Text(
'Diz a ocasiao para a IA sugerir o que levares.',
style: AppText.caption,
),
const SizedBox(height: 16),
Container(
decoration: AppDecorations.outlined(),
child: TextField(
controller: controller,
autofocus: true,
textInputAction: TextInputAction.send,
onSubmitted: (v) => Navigator.pop(ctx, v.trim()),
style: AppText.body,
decoration: const InputDecoration(
hintText: 'Ex: piquenique no parque',
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
),
),
),
const SizedBox(height: 12),
const Text('Sugestoes rapidas', style: AppText.label),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: suggestions
.map(
(s) =>
AppChip(label: s, onTap: () => Navigator.pop(ctx, s)),
)
.toList(),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancelar'),
),
),
const SizedBox(width: 8),
Expanded(
child: AppButton(
label: 'Pedir',
icon: Icons.send_rounded,
onPressed: () =>
Navigator.pop(ctx, controller.text.trim()),
),
),
],
),
],
),
),
),
);
}
Future<void> _requestAiSuggestion() async {
final occasion = await _askOccasion();
if (occasion == null || occasion.trim().isEmpty) return;
if (!mounted) return;
final service = AiRecommendationService();
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => Center(
child: Container(
padding: const EdgeInsets.all(28),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: const Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: AppColors.primary),
SizedBox(height: 16),
Text('A pedir sugestao...', style: AppText.caption),
],
),
),
),
);
final allItems = await service.getItemsWithImages();
final response = await service.sendMessage(occasion.trim(), silent: true);
if (!mounted) return;
Navigator.of(context).pop();
final lines = response
.split('\n')
.map((l) => l.replaceAll(RegExp(r'^[-•*\d.)\s]+'), '').trim())
.where((l) => l.isNotEmpty)
.toList();
final matched = <Map<String, dynamic>>[];
for (final item in allItems) {
final nome = (item['nome'] ?? '').toString().toLowerCase();
for (final line in lines) {
if (nome.isNotEmpty &&
(line.toLowerCase().contains(nome) ||
nome.contains(line.toLowerCase()))) {
matched.add(item);
break;
}
}
}
if (!mounted) return;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _AiSuggestionSheet(
matchedItems: matched,
rawResponse: response,
allItems: allItems,
),
);
}
Widget _buildAddCta() {
return Material(
color: Colors.transparent,
@@ -744,3 +863,392 @@ class _HomeContentState extends State<_HomeContent> {
);
}
}
class _AiSuggestionSheet extends StatelessWidget {
final List<Map<String, dynamic>> matchedItems;
final String rawResponse;
final List<Map<String, dynamic>> allItems;
const _AiSuggestionSheet({
required this.matchedItems,
required this.rawResponse,
required this.allItems,
});
String? _imageUrl(Map<String, dynamic> item) {
final images = item['item_images'] as List?;
if (images != null && images.isNotEmpty) {
return images.first['image_url'] as String?;
}
return null;
}
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.75,
),
margin: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.xl),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 12, 0),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
gradient: AppColors.brandGradient,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: const Icon(
Icons.auto_awesome,
color: Colors.white,
size: 18,
),
),
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: 16),
if (matchedItems.isNotEmpty)
Flexible(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: matchedItems.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (_, i) {
final item = matchedItems[i];
final cat = categoryById(item['categoria'] as String?);
final imgUrl = _imageUrl(item);
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Container(
width: 52,
height: 52,
color: cat.color.withValues(alpha: 0.15),
child: imgUrl != null
? Image.network(
imgUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Icon(
cat.icon,
color: cat.color,
size: 24,
),
)
: Icon(cat.icon, color: cat.color, size: 24),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['nome'] ?? '',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Row(
children: [
Icon(cat.icon, size: 12, color: cat.color),
const SizedBox(width: 4),
Text(
cat.name,
style: TextStyle(
fontSize: 11,
color: cat.color,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
),
],
),
);
},
),
)
else
Padding(
padding: const EdgeInsets.all(16),
child: Text(rawResponse, style: AppText.body),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Row(
children: [
Expanded(
child: _ActionBtn(
icon: Icons.calendar_month_rounded,
label: 'Exportar para dia',
onTap: () => _exportToDay(context),
),
),
],
),
),
],
),
);
}
void _exportToDay(BuildContext context) {
if (matchedItems.isEmpty) {
Navigator.pop(context);
return;
}
final now = DateTime.now();
final startOfWeek = DateTime(
now.year,
now.month,
now.day,
).subtract(Duration(days: now.weekday - 1));
final days = List.generate(7, (i) => startOfWeek.add(Duration(days: i)));
const dayNames = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sab', 'Dom'];
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(ctx).size.height * 0.75,
),
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: [
const Text('Escolher dia', style: AppText.h3),
const SizedBox(height: 4),
const Text(
'Exportar itens sugeridos para qual dia?',
style: AppText.caption,
),
const SizedBox(height: 16),
Flexible(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: List.generate(7, (i) {
final d = days[i];
final isToday =
d.day == now.day &&
d.month == now.month &&
d.year == now.year;
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(AppRadius.md),
onTap: () async {
Navigator.pop(ctx);
Navigator.pop(context);
await _saveToDay(d, context);
},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 8,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isToday
? AppColors.primary.withValues(
alpha: 0.12,
)
: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(
AppRadius.md,
),
),
child: Center(
child: Text(
'${d.day}',
style: TextStyle(
fontWeight: FontWeight.w700,
color: isToday
? AppColors.primary
: AppColors.textPrimary,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'${dayNames[i]}${isToday ? ' (hoje)' : ''}',
style: TextStyle(
fontWeight: FontWeight.w600,
color: isToday
? AppColors.primary
: AppColors.textPrimary,
),
),
),
const Icon(
Icons.arrow_forward_ios_rounded,
size: 14,
color: AppColors.textTertiary,
),
],
),
),
),
);
}),
),
),
),
],
),
),
);
}
Future<void> _saveToDay(DateTime day, BuildContext context) async {
try {
final user = Supabase.instance.client.auth.currentUser;
if (user == null) return;
final dateStr =
'${day.year.toString().padLeft(4, '0')}-${day.month.toString().padLeft(2, '0')}-${day.day.toString().padLeft(2, '0')}';
final existing = await Supabase.instance.client
.from('plans')
.select('id')
.eq('user_id', user.id)
.eq('data', dateStr)
.maybeSingle();
final int planId;
if (existing != null) {
planId = existing['id'] as int;
} else {
final created = await Supabase.instance.client
.from('plans')
.insert({'user_id': user.id, 'data': dateStr})
.select()
.single();
planId = created['id'] as int;
}
final existingItems = await Supabase.instance.client
.from('plan_items')
.select('item_id')
.eq('plan_id', planId);
final existingIds = (existingItems as List)
.map((e) => e['item_id'])
.toSet();
final toInsert = matchedItems
.where((item) => !existingIds.contains(item['id']))
.map((item) => {'plan_id': planId, 'item_id': item['id']})
.toList();
if (toInsert.isNotEmpty) {
await Supabase.instance.client.from('plan_items').insert(toInsert);
}
if (context.mounted) {
AppSnack.success(
context,
'${matchedItems.length} itens exportados para ${day.day}/${day.month}',
);
}
} catch (e) {
if (context.mounted) {
AppSnack.error(context, 'Erro ao exportar: $e');
}
}
}
}
class _ActionBtn extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _ActionBtn({
required this.icon,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(AppRadius.md),
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
gradient: AppColors.brandGradient,
borderRadius: BorderRadius.circular(AppRadius.md),
boxShadow: AppShadows.brand,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: Colors.white, size: 20),
const SizedBox(width: 8),
Text(
label,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
],
),
),
),
);
}
}

View File

@@ -325,7 +325,7 @@ class _ItemScreenState extends State<ItemScreen> {
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.50,
childAspectRatio: 0.70,
),
itemCount: _filteredItems.length,
itemBuilder: (_, i) => _buildGridCard(_filteredItems[i]),
@@ -356,8 +356,7 @@ class _ItemScreenState extends State<ItemScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
@@ -377,7 +376,7 @@ class _ItemScreenState extends State<ItemScreen> {
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 12),
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@@ -37,7 +37,8 @@ class _LoginScreenState extends State<LoginScreen>
padding: const EdgeInsets.symmetric(horizontal: 24),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: MediaQuery.of(context).size.height -
minHeight:
MediaQuery.of(context).size.height -
MediaQuery.of(context).padding.top -
MediaQuery.of(context).padding.bottom,
),
@@ -55,10 +56,7 @@ class _LoginScreenState extends State<LoginScreen>
const Spacer(),
Padding(
padding: const EdgeInsets.only(top: 24, bottom: 8),
child: Text(
'Versão 1.0.0',
style: AppText.caption,
),
child: Text('Versão 1.0.0', style: AppText.caption),
),
],
),
@@ -75,16 +73,9 @@ class _LoginScreenState extends State<LoginScreen>
Container(
width: 84,
height: 84,
decoration: BoxDecoration(
gradient: AppColors.brandGradient,
borderRadius: BorderRadius.circular(24),
boxShadow: AppShadows.brand,
),
child: const Icon(
Icons.auto_awesome_rounded,
color: Colors.white,
size: 42,
),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(24)),
clipBehavior: Clip.antiAlias,
child: Image.asset('assets/logoDayMaker.png', fit: BoxFit.cover),
),
const SizedBox(height: 18),
const Text('DayMaker', style: AppText.h1),
@@ -245,8 +236,7 @@ class _LoginScreenState extends State<LoginScreen>
}
setState(() => _isLoading = true);
try {
final response =
await Supabase.instance.client.auth.signInWithPassword(
final response = await Supabase.instance.client.auth.signInWithPassword(
email: email,
password: password,
);

View File

@@ -7,7 +7,7 @@ class AiRecommendationService {
static const String _model = 'llama3.2:3b';
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.';
'es um assistente que ajuda a montar outfits e escolher o que levar para o dia ou viagem. usa linguagem simples e curta, sem emojis. baseia-te nas tags e notas dos itens do utilizador. responde sempre em portugues e se breve.';
final List<Map<String, String>> _history = [];
@@ -52,7 +52,7 @@ class AiRecommendationService {
];
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\n\n[Instrucao: responde APENAS com os nomes exatos dos itens do meu inventario que sugeres, um por linha, sem numeracao, sem explicacao, sem comentarios.]'
: userMessage;
messages.add({'role': 'user', 'content': userContent});
@@ -113,4 +113,18 @@ class AiRecommendationService {
}
void clearHistory() => _history.clear();
Future<List<Map<String, dynamic>>> getItemsWithImages() async {
try {
final user = Supabase.instance.client.auth.currentUser;
if (user == null) return [];
final rows = await Supabase.instance.client
.from('items')
.select('*, item_images(image_url)')
.eq('user_id', user.id);
return List<Map<String, dynamic>>.from(rows);
} catch (_) {
return [];
}
}
}