IA Funcinonal
This commit is contained in:
@@ -23,6 +23,7 @@ linter:
|
|||||||
rules:
|
rules:
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
unnecessary_underscores: false
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
-- Create items table
|
|
||||||
CREATE TABLE items (
|
|
||||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
category_id TEXT NOT NULL,
|
|
||||||
subcategory_id TEXT,
|
|
||||||
context_tags TEXT[] DEFAULT '{}',
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Create index on user_id for faster queries
|
|
||||||
CREATE INDEX idx_items_user_id ON items(user_id);
|
|
||||||
|
|
||||||
-- Create index on category_id for filtering
|
|
||||||
CREATE INDEX idx_items_category_id ON items(category_id);
|
|
||||||
|
|
||||||
-- Create index on context_tags for tag-based searches
|
|
||||||
CREATE INDEX idx_items_context_tags ON items USING GIN(context_tags);
|
|
||||||
|
|
||||||
-- Enable Row Level Security
|
|
||||||
ALTER TABLE items ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
-- Create policy to allow users to see their own items
|
|
||||||
CREATE POLICY "Users can view own items"
|
|
||||||
ON items FOR SELECT
|
|
||||||
USING (auth.uid() = user_id);
|
|
||||||
|
|
||||||
-- Create policy to allow users to insert their own items
|
|
||||||
CREATE POLICY "Users can insert own items"
|
|
||||||
ON items FOR INSERT
|
|
||||||
WITH CHECK (auth.uid() = user_id);
|
|
||||||
|
|
||||||
-- Create policy to allow users to update their own items
|
|
||||||
CREATE POLICY "Users can update own items"
|
|
||||||
ON items FOR UPDATE
|
|
||||||
USING (auth.uid() = user_id);
|
|
||||||
|
|
||||||
-- Create policy to allow users to delete their own items
|
|
||||||
CREATE POLICY "Users can delete own items"
|
|
||||||
ON items FOR DELETE
|
|
||||||
USING (auth.uid() = user_id);
|
|
||||||
|
|
||||||
-- Create trigger to update updated_at timestamp
|
|
||||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = NOW();
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ language 'plpgsql';
|
|
||||||
|
|
||||||
CREATE TRIGGER update_items_updated_at
|
|
||||||
BEFORE UPDATE ON items
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_updated_at_column();
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../services/ai_recommendation_service.dart';
|
import '../services/ai_recommendation_service.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
class AiChatScreen extends StatefulWidget {
|
class AiChatScreen extends StatefulWidget {
|
||||||
const AiChatScreen({super.key});
|
const AiChatScreen({super.key});
|
||||||
@@ -21,19 +22,25 @@ class _AiChatScreenState extends State<AiChatScreen> {
|
|||||||
];
|
];
|
||||||
bool _isLoading = false;
|
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();
|
final text = (suggestion ?? _controller.text).trim();
|
||||||
if (text.isEmpty || _isLoading) return;
|
if (text.isEmpty || _isLoading) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_messages.add(_ChatMessage(text: text, isUser: true));
|
if (!hideUserMessage) {
|
||||||
|
_messages.add(_ChatMessage(text: text, isUser: true));
|
||||||
|
}
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
});
|
});
|
||||||
_controller.clear();
|
_controller.clear();
|
||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await _service.recommendForOccasion(text);
|
final response = await _service.sendMessage(text, silent: silent);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_messages.add(_ChatMessage(text: response, isUser: false));
|
_messages.add(_ChatMessage(text: response, isUser: false));
|
||||||
@@ -43,7 +50,7 @@ class _AiChatScreenState extends State<AiChatScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_messages.add(
|
_messages.add(
|
||||||
_ChatMessage(
|
_ChatMessage(
|
||||||
text: 'Não consegui gerar uma recomendação agora. Tenta novamente.',
|
text: 'Nao consegui gerar uma recomendacao agora. Tenta novamente.',
|
||||||
isUser: false,
|
isUser: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -77,24 +84,16 @@ class _AiChatScreenState extends State<AiChatScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFFFE5CC),
|
backgroundColor: AppColors.background,
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: const Color(0xFF0066CC),
|
|
||||||
elevation: 0,
|
|
||||||
title: const Text(
|
|
||||||
'DayMaker IA',
|
|
||||||
style: TextStyle(color: Colors.white, fontSize: 20),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
|
||||||
),
|
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
_buildHeader(),
|
||||||
_buildSuggestions(),
|
_buildSuggestions(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
itemCount: _messages.length + (_isLoading ? 1 : 0),
|
itemCount: _messages.length + (_isLoading ? 1 : 0),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (_isLoading && index == _messages.length) {
|
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() {
|
Widget _buildSuggestions() {
|
||||||
final suggestions = [
|
final suggestions = [
|
||||||
'Viagem para Itália',
|
'Viagem para Itália',
|
||||||
@@ -120,19 +167,18 @@ class _AiChatScreenState extends State<AiChatScreen> {
|
|||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
|
||||||
color: const Color(0xFFFFE5CC),
|
color: AppColors.background,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: suggestions.map((suggestion) {
|
children: suggestions.map((suggestion) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: ActionChip(
|
child: AppChip(
|
||||||
backgroundColor: Colors.white,
|
label: suggestion,
|
||||||
side: const BorderSide(color: Color(0xFFE0E0E0)),
|
icon: Icons.bolt_rounded,
|
||||||
label: Text(suggestion),
|
onTap: () => _sendMessage(suggestion: suggestion),
|
||||||
onPressed: () => _sendMessage(suggestion),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
@@ -143,15 +189,25 @@ class _AiChatScreenState extends State<AiChatScreen> {
|
|||||||
|
|
||||||
Widget _buildInput() {
|
Widget _buildInput() {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
padding: const EdgeInsets.fromLTRB(12, 10, 12, 14),
|
||||||
decoration: const BoxDecoration(color: Colors.white),
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.04),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, -4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF5F5F5),
|
color: AppColors.surfaceAlt,
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
),
|
),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
@@ -159,8 +215,10 @@ class _AiChatScreenState extends State<AiChatScreen> {
|
|||||||
maxLines: 4,
|
maxLines: 4,
|
||||||
textInputAction: TextInputAction.send,
|
textInputAction: TextInputAction.send,
|
||||||
onSubmitted: (_) => _sendMessage(),
|
onSubmitted: (_) => _sendMessage(),
|
||||||
|
style: AppText.body,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: 'Ex: viagem para Itália de 5 dias...',
|
hintText: 'Escreve uma mensagem...',
|
||||||
|
hintStyle: TextStyle(color: AppColors.textTertiary),
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
contentPadding: EdgeInsets.symmetric(
|
contentPadding: EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
@@ -170,18 +228,27 @@ class _AiChatScreenState extends State<AiChatScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 10),
|
||||||
SizedBox(
|
Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
child: ElevatedButton(
|
decoration: BoxDecoration(
|
||||||
onPressed: _isLoading ? null : () => _sendMessage(),
|
gradient: AppColors.brandGradient,
|
||||||
style: ElevatedButton.styleFrom(
|
shape: BoxShape.circle,
|
||||||
backgroundColor: const Color(0xFF0066CC),
|
boxShadow: AppShadows.brand,
|
||||||
shape: const CircleBorder(),
|
),
|
||||||
padding: EdgeInsets.zero,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final alignment = message.isUser
|
final isUser = message.isUser;
|
||||||
? Alignment.centerRight
|
final alignment = isUser ? Alignment.centerRight : Alignment.centerLeft;
|
||||||
: Alignment.centerLeft;
|
|
||||||
final backgroundColor = message.isUser
|
|
||||||
? const Color(0xFF0066CC)
|
|
||||||
: Colors.white;
|
|
||||||
final textColor = message.isUser ? Colors.white : const Color(0xFF333333);
|
|
||||||
|
|
||||||
return Align(
|
return Align(
|
||||||
alignment: alignment,
|
alignment: alignment,
|
||||||
@@ -219,14 +281,25 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
maxWidth: MediaQuery.of(context).size.width * 0.78,
|
maxWidth: MediaQuery.of(context).size.width * 0.78,
|
||||||
),
|
),
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: backgroundColor,
|
gradient: isUser ? AppColors.brandGradient : null,
|
||||||
borderRadius: BorderRadius.circular(18),
|
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(
|
child: Text(
|
||||||
message.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,
|
alignment: Alignment.centerLeft,
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: AppColors.surface,
|
||||||
borderRadius: BorderRadius.circular(18),
|
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(
|
child: const SizedBox(
|
||||||
width: 18,
|
width: 20,
|
||||||
height: 18,
|
height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primary),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import '../constants/item_categories.dart';
|
import '../constants/item_categories.dart';
|
||||||
|
import '../services/ai_recommendation_service.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import 'add_item_screen.dart';
|
import 'add_item_screen.dart';
|
||||||
|
import 'ai_chat_screen.dart';
|
||||||
import 'item_screen.dart';
|
import 'item_screen.dart';
|
||||||
import 'perfil_screen.dart';
|
import 'perfil_screen.dart';
|
||||||
import 'week_screen.dart';
|
import 'week_screen.dart';
|
||||||
@@ -21,6 +23,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
_HomeContent(),
|
_HomeContent(),
|
||||||
ItemScreen(),
|
ItemScreen(),
|
||||||
WeekScreen(),
|
WeekScreen(),
|
||||||
|
AiChatScreen(),
|
||||||
PerfilScreen(),
|
PerfilScreen(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -55,7 +58,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
_navItem(0, Icons.home_rounded, 'Início'),
|
_navItem(0, Icons.home_rounded, 'Início'),
|
||||||
_navItem(1, Icons.inventory_2_rounded, 'Itens'),
|
_navItem(1, Icons.inventory_2_rounded, 'Itens'),
|
||||||
_navItem(2, Icons.calendar_month_rounded, 'Semana'),
|
_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(
|
||||||
icon,
|
icon,
|
||||||
size: 24,
|
size: 24,
|
||||||
color:
|
color: selected ? AppColors.primary : AppColors.textSecondary,
|
||||||
selected ? AppColors.primary : AppColors.textSecondary,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight:
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||||
selected ? FontWeight.w700 : FontWeight.w500,
|
|
||||||
color: selected
|
color: selected
|
||||||
? AppColors.primary
|
? AppColors.primary
|
||||||
: AppColors.textSecondary,
|
: AppColors.textSecondary,
|
||||||
@@ -198,9 +200,8 @@ class _HomeContentState extends State<_HomeContent> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_itemCount = all.length;
|
_itemCount = all.length;
|
||||||
_userName = userRow?['nome'] ??
|
_userName =
|
||||||
user.email?.split('@').first ??
|
userRow?['nome'] ?? user.email?.split('@').first ?? 'Utilizador';
|
||||||
'Utilizador';
|
|
||||||
_recentItems = List<Map<String, dynamic>>.from(recent);
|
_recentItems = List<Map<String, dynamic>>.from(recent);
|
||||||
_todayItems = todayItems;
|
_todayItems = todayItems;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -227,6 +228,8 @@ class _HomeContentState extends State<_HomeContent> {
|
|||||||
_buildGreeting(),
|
_buildGreeting(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildHeroCard(),
|
_buildHeroCard(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildAiSuggestionButton(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_buildSectionHeader('Hoje'),
|
_buildSectionHeader('Hoje'),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -249,23 +252,17 @@ class _HomeContentState extends State<_HomeContent> {
|
|||||||
final saudacao = hour < 12
|
final saudacao = hour < 12
|
||||||
? 'Bom dia'
|
? 'Bom dia'
|
||||||
: hour < 19
|
: hour < 19
|
||||||
? 'Boa tarde'
|
? 'Boa tarde'
|
||||||
: 'Boa noite';
|
: 'Boa noite';
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(saudacao, style: AppText.bodySecondary),
|
||||||
saudacao,
|
|
||||||
style: AppText.bodySecondary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(_userName.isEmpty ? 'Olá!' : _userName, style: AppText.h2),
|
||||||
_userName.isEmpty ? 'Olá!' : _userName,
|
|
||||||
style: AppText.h2,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -413,10 +410,7 @@ class _HomeContentState extends State<_HomeContent> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text('Nada planeado para hoje', style: AppText.body),
|
||||||
'Nada planeado para hoje',
|
|
||||||
style: AppText.body,
|
|
||||||
),
|
|
||||||
SizedBox(height: 2),
|
SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'Vá à aba Semana para organizar',
|
'Vá à aba Semana para organizar',
|
||||||
@@ -454,17 +448,11 @@ class _HomeContentState extends State<_HomeContent> {
|
|||||||
color: AppColors.accent.withValues(alpha: 0.15),
|
color: AppColors.accent.withValues(alpha: 0.15),
|
||||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(Icons.add_box_rounded, color: AppColors.accent),
|
||||||
Icons.add_box_rounded,
|
|
||||||
color: AppColors.accent,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: Text(
|
child: Text('Adicione o seu primeiro item', style: AppText.body),
|
||||||
'Adicione o seu primeiro item',
|
|
||||||
style: AppText.body,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -509,20 +497,12 @@ class _HomeContentState extends State<_HomeContent> {
|
|||||||
? Image.network(
|
? Image.network(
|
||||||
imageUrl,
|
imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => Center(
|
errorBuilder: (_, _, _) => Center(
|
||||||
child: Icon(
|
child: Icon(cat.icon, color: cat.color, size: 32),
|
||||||
cat.icon,
|
|
||||||
color: cat.color,
|
|
||||||
size: 32,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Center(
|
: Center(
|
||||||
child: Icon(
|
child: Icon(cat.icon, color: cat.color, size: 32),
|
||||||
cat.icon,
|
|
||||||
color: cat.color,
|
|
||||||
size: 32,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -569,7 +549,7 @@ class _HomeContentState extends State<_HomeContent> {
|
|||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
itemCount: 3,
|
itemCount: 3,
|
||||||
itemBuilder: (_, __) => Container(
|
itemBuilder: (_, _) => Container(
|
||||||
width: 110,
|
width: 110,
|
||||||
margin: const EdgeInsets.only(right: 12),
|
margin: const EdgeInsets.only(right: 12),
|
||||||
decoration: BoxDecoration(
|
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() {
|
Widget _buildAddCta() {
|
||||||
return Material(
|
return Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
@@ -588,9 +689,7 @@ class _HomeContentState extends State<_HomeContent> {
|
|||||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context)
|
Navigator.of(context)
|
||||||
.push(
|
.push(MaterialPageRoute(builder: (_) => const AddItemScreen()))
|
||||||
MaterialPageRoute(builder: (_) => const AddItemScreen()),
|
|
||||||
)
|
|
||||||
.then((_) => _loadData());
|
.then((_) => _loadData());
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -613,8 +712,11 @@ class _HomeContentState extends State<_HomeContent> {
|
|||||||
gradient: AppColors.brandGradient,
|
gradient: AppColors.brandGradient,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
),
|
),
|
||||||
child:
|
child: const Icon(
|
||||||
const Icon(Icons.add_rounded, color: Colors.white, size: 24),
|
Icons.add_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
|
|||||||
@@ -21,12 +21,15 @@ class _ItemScreenState extends State<ItemScreen> {
|
|||||||
List<Map<String, dynamic>> get _filteredItems {
|
List<Map<String, dynamic>> get _filteredItems {
|
||||||
return _items.where((item) {
|
return _items.where((item) {
|
||||||
final name = (item['nome'] ?? '').toString().toLowerCase();
|
final name = (item['nome'] ?? '').toString().toLowerCase();
|
||||||
final tags =
|
final tags = List<String>.from(
|
||||||
List<String>.from(item['tags'] ?? []).join(' ').toLowerCase();
|
item['tags'] ?? [],
|
||||||
final matchesSearch = _searchQuery.isEmpty ||
|
).join(' ').toLowerCase();
|
||||||
|
final matchesSearch =
|
||||||
|
_searchQuery.isEmpty ||
|
||||||
name.contains(_searchQuery.toLowerCase()) ||
|
name.contains(_searchQuery.toLowerCase()) ||
|
||||||
tags.contains(_searchQuery.toLowerCase());
|
tags.contains(_searchQuery.toLowerCase());
|
||||||
final matchesCategory = _selectedCategoryFilter == null ||
|
final matchesCategory =
|
||||||
|
_selectedCategoryFilter == null ||
|
||||||
item['categoria'] == _selectedCategoryFilter;
|
item['categoria'] == _selectedCategoryFilter;
|
||||||
return matchesSearch && matchesCategory;
|
return matchesSearch && matchesCategory;
|
||||||
}).toList();
|
}).toList();
|
||||||
@@ -110,8 +113,7 @@ class _ItemScreenState extends State<ItemScreen> {
|
|||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) =>
|
builder: (_) => ItemDetailScreen(item: item, imageUrl: _imageUrl(item)),
|
||||||
ItemDetailScreen(item: item, imageUrl: _imageUrl(item)),
|
|
||||||
),
|
),
|
||||||
).then((_) => _loadItems());
|
).then((_) => _loadItems());
|
||||||
}
|
}
|
||||||
@@ -140,16 +142,14 @@ class _ItemScreenState extends State<ItemScreen> {
|
|||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _items.isEmpty
|
: _items.isEmpty
|
||||||
? _buildEmpty()
|
? _buildEmpty()
|
||||||
: _filteredItems.isEmpty
|
: _filteredItems.isEmpty
|
||||||
? _buildNoResults()
|
? _buildNoResults()
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: _loadItems,
|
onRefresh: _loadItems,
|
||||||
color: AppColors.primary,
|
color: AppColors.primary,
|
||||||
child: _gridView
|
child: _gridView ? _buildGrid() : _buildList(),
|
||||||
? _buildGrid()
|
),
|
||||||
: _buildList(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -211,8 +211,7 @@ class _ItemScreenState extends State<ItemScreen> {
|
|||||||
child: Icon(
|
child: Icon(
|
||||||
icon,
|
icon,
|
||||||
size: 18,
|
size: 18,
|
||||||
color:
|
color: selected ? AppColors.primary : AppColors.textSecondary,
|
||||||
selected ? AppColors.primary : AppColors.textSecondary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -266,8 +265,7 @@ class _ItemScreenState extends State<ItemScreen> {
|
|||||||
icon: c.icon,
|
icon: c.icon,
|
||||||
color: c.color,
|
color: c.color,
|
||||||
selected: _selectedCategoryFilter == c.id,
|
selected: _selectedCategoryFilter == c.id,
|
||||||
onTap: () =>
|
onTap: () => setState(() => _selectedCategoryFilter = c.id),
|
||||||
setState(() => _selectedCategoryFilter = c.id),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -327,7 +325,7 @@ class _ItemScreenState extends State<ItemScreen> {
|
|||||||
crossAxisCount: 2,
|
crossAxisCount: 2,
|
||||||
mainAxisSpacing: 12,
|
mainAxisSpacing: 12,
|
||||||
crossAxisSpacing: 12,
|
crossAxisSpacing: 12,
|
||||||
childAspectRatio: 0.78,
|
childAspectRatio: 0.50,
|
||||||
),
|
),
|
||||||
itemCount: _filteredItems.length,
|
itemCount: _filteredItems.length,
|
||||||
itemBuilder: (_, i) => _buildGridCard(_filteredItems[i]),
|
itemBuilder: (_, i) => _buildGridCard(_filteredItems[i]),
|
||||||
@@ -368,18 +366,13 @@ class _ItemScreenState extends State<ItemScreen> {
|
|||||||
Image.network(
|
Image.network(
|
||||||
imageUrl,
|
imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) =>
|
errorBuilder: (_, __, ___) => Center(
|
||||||
Center(child: Icon(cat.icon, color: cat.color, size: 40)),
|
child: Icon(cat.icon, color: cat.color, size: 40),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Center(
|
Center(child: Icon(cat.icon, color: cat.color, size: 40)),
|
||||||
child: Icon(cat.icon, color: cat.color, size: 40),
|
Positioned(top: 8, right: 8, child: _moreButton(item)),
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
child: _moreButton(item),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -463,11 +456,8 @@ class _ItemScreenState extends State<ItemScreen> {
|
|||||||
? Image.network(
|
? Image.network(
|
||||||
imageUrl,
|
imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => Icon(
|
errorBuilder: (_, __, ___) =>
|
||||||
cat.icon,
|
Icon(cat.icon, color: cat.color, size: 28),
|
||||||
color: cat.color,
|
|
||||||
size: 28,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: 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) {
|
Widget _moreButton(Map<String, dynamic> item) {
|
||||||
return Material(
|
return Material(
|
||||||
color: Colors.white.withValues(alpha: 0.85),
|
color: Colors.white.withValues(alpha: 0.75),
|
||||||
shape: const CircleBorder(),
|
shape: const CircleBorder(),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
@@ -575,15 +565,10 @@ class _ItemScreenState extends State<ItemScreen> {
|
|||||||
_openItem(item);
|
_openItem(item);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_actionTile(
|
_actionTile(Icons.edit_outlined, 'Editar', AppColors.primary, () {
|
||||||
Icons.edit_outlined,
|
Navigator.pop(ctx);
|
||||||
'Editar',
|
_editItem(item);
|
||||||
AppColors.primary,
|
}),
|
||||||
() {
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
_editItem(item);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
_actionTile(
|
_actionTile(
|
||||||
Icons.delete_outline_rounded,
|
Icons.delete_outline_rounded,
|
||||||
'Apagar',
|
'Apagar',
|
||||||
@@ -619,10 +604,7 @@ class _ItemScreenState extends State<ItemScreen> {
|
|||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
label,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(color: color, fontWeight: FontWeight.w600),
|
||||||
color: color,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
);
|
);
|
||||||
@@ -714,13 +696,12 @@ class ItemDetailScreen extends StatelessWidget {
|
|||||||
Image.network(
|
Image.network(
|
||||||
imageUrl!,
|
imageUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) =>
|
errorBuilder: (_, __, ___) => Center(
|
||||||
Center(child: Icon(cat.icon, color: cat.color, size: 80)),
|
child: Icon(cat.icon, color: cat.color, size: 80),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Center(
|
Center(child: Icon(cat.icon, color: cat.color, size: 80)),
|
||||||
child: Icon(cat.icon, color: cat.color, size: 80),
|
|
||||||
),
|
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
@@ -786,8 +767,9 @@ class ItemDetailScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.surface,
|
color: AppColors.surface,
|
||||||
borderRadius:
|
borderRadius: BorderRadius.circular(
|
||||||
BorderRadius.circular(AppRadius.pill),
|
AppRadius.pill,
|
||||||
|
),
|
||||||
border: Border.all(color: AppColors.border),
|
border: Border.all(color: AppColors.border),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -853,11 +835,14 @@ class _EditItemScreenState extends State<EditItemScreen> {
|
|||||||
}
|
}
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
await Supabase.instance.client.from('items').update({
|
await Supabase.instance.client
|
||||||
'nome': _nameController.text.trim(),
|
.from('items')
|
||||||
'categoria': _selectedCategory?.id,
|
.update({
|
||||||
'tags': _selectedTags.toList(),
|
'nome': _nameController.text.trim(),
|
||||||
}).eq('id', widget.item['id']);
|
'categoria': _selectedCategory?.id,
|
||||||
|
'tags': _selectedTags.toList(),
|
||||||
|
})
|
||||||
|
.eq('id', widget.item['id']);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
AppSnack.success(context, 'Item atualizado!');
|
AppSnack.success(context, 'Item atualizado!');
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ class _PerfilScreenState extends State<PerfilScreen> {
|
|||||||
? Image.network(
|
? Image.network(
|
||||||
_avatarUrl!,
|
_avatarUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => Container(
|
errorBuilder: (_, _, _) => Container(
|
||||||
color: AppColors.surface,
|
color: AppColors.surface,
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.person_rounded,
|
Icons.person_rounded,
|
||||||
|
|||||||
@@ -15,7 +15,15 @@ class _WeekScreenState extends State<WeekScreen> {
|
|||||||
List<Map<String, dynamic>> _dayItems = [];
|
List<Map<String, dynamic>> _dayItems = [];
|
||||||
bool _isLoading = false;
|
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 = [
|
static const _weekdayLong = [
|
||||||
'Segunda',
|
'Segunda',
|
||||||
'Terça',
|
'Terça',
|
||||||
@@ -34,8 +42,11 @@ class _WeekScreenState extends State<WeekScreen> {
|
|||||||
|
|
||||||
DateTime get _startOfWeek {
|
DateTime get _startOfWeek {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
return DateTime(now.year, now.month, now.day)
|
return DateTime(
|
||||||
.subtract(Duration(days: now.weekday - 1));
|
now.year,
|
||||||
|
now.month,
|
||||||
|
now.day,
|
||||||
|
).subtract(Duration(days: now.weekday - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
String _dateKey(DateTime d) =>
|
String _dateKey(DateTime d) =>
|
||||||
@@ -108,8 +119,9 @@ class _WeekScreenState extends State<WeekScreen> {
|
|||||||
.eq('user_id', user.id);
|
.eq('user_id', user.id);
|
||||||
final available = List<Map<String, dynamic>>.from(allItems);
|
final available = List<Map<String, dynamic>>.from(allItems);
|
||||||
final existingIds = _dayItems.map((i) => i['id']).toSet();
|
final existingIds = _dayItems.map((i) => i['id']).toSet();
|
||||||
final toShow =
|
final toShow = available
|
||||||
available.where((i) => !existingIds.contains(i['id'])).toList();
|
.where((i) => !existingIds.contains(i['id']))
|
||||||
|
.toList();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final selected = await showModalBottomSheet<List<int>>(
|
final selected = await showModalBottomSheet<List<int>>(
|
||||||
@@ -122,8 +134,9 @@ class _WeekScreenState extends State<WeekScreen> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final planId = await _getOrCreatePlanId(_selectedDay);
|
final planId = await _getOrCreatePlanId(_selectedDay);
|
||||||
final rows =
|
final rows = selected
|
||||||
selected.map((id) => {'plan_id': planId, 'item_id': id}).toList();
|
.map((id) => {'plan_id': planId, 'item_id': id})
|
||||||
|
.toList();
|
||||||
await Supabase.instance.client.from('plan_items').insert(rows);
|
await Supabase.instance.client.from('plan_items').insert(rows);
|
||||||
_loadDayItems();
|
_loadDayItems();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -163,15 +176,12 @@ class _WeekScreenState extends State<WeekScreen> {
|
|||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _dayItems.isEmpty
|
: _dayItems.isEmpty
|
||||||
? _buildEmpty()
|
? _buildEmpty()
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 120),
|
||||||
20, 0, 20, 120,
|
itemCount: _dayItems.length,
|
||||||
),
|
itemBuilder: (_, i) => _buildItemTile(_dayItems[i]),
|
||||||
itemCount: _dayItems.length,
|
),
|
||||||
itemBuilder: (_, i) =>
|
|
||||||
_buildItemTile(_dayItems[i]),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -188,10 +198,7 @@ class _WeekScreenState extends State<WeekScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text('Minha Semana', style: AppText.h2),
|
Text('Minha Semana', style: AppText.h2),
|
||||||
SizedBox(height: 2),
|
SizedBox(height: 2),
|
||||||
Text(
|
Text('Planeie o que precisa para cada dia', style: AppText.caption),
|
||||||
'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),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: days.map((day) {
|
children: days.map((day) {
|
||||||
final isSelected = day.year == _selectedDay.year &&
|
final isSelected =
|
||||||
|
day.year == _selectedDay.year &&
|
||||||
day.month == _selectedDay.month &&
|
day.month == _selectedDay.month &&
|
||||||
day.day == _selectedDay.day;
|
day.day == _selectedDay.day;
|
||||||
final today = DateTime.now();
|
final today = DateTime.now();
|
||||||
final isToday = day.year == today.year &&
|
final isToday =
|
||||||
|
day.year == today.year &&
|
||||||
day.month == today.month &&
|
day.month == today.month &&
|
||||||
day.day == today.day;
|
day.day == today.day;
|
||||||
return Expanded(
|
return Expanded(
|
||||||
@@ -322,10 +331,7 @@ class _WeekScreenState extends State<WeekScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text('Nada planeado', style: AppText.h3),
|
const Text('Nada planeado', style: AppText.h3),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text('Toque em + para adicionar itens', style: AppText.caption),
|
||||||
'Toque em + para adicionar itens',
|
|
||||||
style: AppText.caption,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -369,7 +375,7 @@ class _WeekScreenState extends State<WeekScreen> {
|
|||||||
? Image.network(
|
? Image.network(
|
||||||
imageUrl,
|
imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) =>
|
errorBuilder: (_, _, _) =>
|
||||||
Icon(cat.icon, color: cat.color, size: 24),
|
Icon(cat.icon, color: cat.color, size: 24),
|
||||||
)
|
)
|
||||||
: 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) {
|
Widget build(BuildContext context) {
|
||||||
final filtered = widget.items
|
final filtered = widget.items
|
||||||
.where(
|
.where(
|
||||||
(i) => (i['nome'] ?? '')
|
(i) => (i['nome'] ?? '').toString().toLowerCase().contains(
|
||||||
.toString()
|
_query.toLowerCase(),
|
||||||
.toLowerCase()
|
),
|
||||||
.contains(_query.toLowerCase()),
|
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
@@ -541,8 +546,7 @@ class _ItemPickerSheetState extends State<_ItemPickerSheet> {
|
|||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
|
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
|
||||||
itemCount: filtered.length,
|
itemCount: filtered.length,
|
||||||
itemBuilder: (_, i) =>
|
itemBuilder: (_, i) => _buildPickerTile(filtered[i]),
|
||||||
_buildPickerTile(filtered[i]),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SafeArea(
|
SafeArea(
|
||||||
@@ -556,8 +560,7 @@ class _ItemPickerSheetState extends State<_ItemPickerSheet> {
|
|||||||
icon: Icons.check_rounded,
|
icon: Icons.check_rounded,
|
||||||
onPressed: _selected.isEmpty
|
onPressed: _selected.isEmpty
|
||||||
? null
|
? null
|
||||||
: () =>
|
: () => Navigator.pop(context, _selected.toList()),
|
||||||
Navigator.pop(context, _selected.toList()),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -612,7 +615,7 @@ class _ItemPickerSheetState extends State<_ItemPickerSheet> {
|
|||||||
? Image.network(
|
? Image.network(
|
||||||
imageUrl,
|
imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) =>
|
errorBuilder: (_, _, _) =>
|
||||||
Icon(cat.icon, color: cat.color, size: 22),
|
Icon(cat.icon, color: cat.color, size: 22),
|
||||||
)
|
)
|
||||||
: 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,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: selected
|
color: selected ? AppColors.primary : Colors.transparent,
|
||||||
? AppColors.primary
|
|
||||||
: Colors.transparent,
|
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: selected
|
color: selected ? AppColors.primary : AppColors.border,
|
||||||
? AppColors.primary
|
|
||||||
: AppColors.border,
|
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,128 +1,116 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
class AiRecommendationService {
|
class AiRecommendationService {
|
||||||
Future<String> recommendForOccasion(String prompt) async {
|
static const String _apiUrl = 'https://apichat.epvc.pt/api/chat';
|
||||||
final input = prompt.trim();
|
static const String _model = 'llama3.2:3b';
|
||||||
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<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 List<Map<String, String>> _history = [];
|
||||||
final items = <String>{};
|
|
||||||
final tips = <String>[];
|
|
||||||
|
|
||||||
items.addAll([
|
Future<String> _itemsContext() async {
|
||||||
'Documento de identificação ou passaporte',
|
try {
|
||||||
'Carteira/cartões e algum dinheiro',
|
final user = Supabase.instance.client.auth.currentUser;
|
||||||
'Telemóvel e carregador',
|
if (user == null) return '';
|
||||||
'Power bank',
|
final rows = await Supabase.instance.client
|
||||||
'Produtos de higiene pessoal',
|
.from('items')
|
||||||
'Roupa interior e meias suficientes',
|
.select()
|
||||||
'Medicamentos pessoais',
|
.eq('user_id', user.id);
|
||||||
]);
|
if (rows.isEmpty) return '';
|
||||||
|
final buf = StringBuffer(
|
||||||
if (_containsAny(lower, [
|
'Itens disponiveis no inventario do utilizador:\n',
|
||||||
'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.',
|
|
||||||
);
|
);
|
||||||
|
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) {
|
Future<String> sendMessage(String userMessage, {bool silent = false}) async {
|
||||||
return terms.any(text.contains);
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.3"
|
version: "1.0.3"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ dependencies:
|
|||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
supabase_flutter: ^2.3.3
|
supabase_flutter: ^2.3.3
|
||||||
image_picker: ^1.0.7
|
image_picker: ^1.0.7
|
||||||
|
http: ^1.2.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user