Files
dayMaker_lp/lib/Screens/item_screen.dart
Carlos Correia 967584f083 IA Funcinonal
2026-05-21 11:53:35 +01:00

943 lines
30 KiB
Dart

import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../constants/item_categories.dart';
import '../theme/app_theme.dart';
import 'add_item_screen.dart';
class ItemScreen extends StatefulWidget {
const ItemScreen({super.key});
@override
State<ItemScreen> createState() => _ItemScreenState();
}
class _ItemScreenState extends State<ItemScreen> {
List<Map<String, dynamic>> _items = [];
bool _isLoading = true;
String _searchQuery = '';
String? _selectedCategoryFilter;
bool _gridView = true;
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 ||
name.contains(_searchQuery.toLowerCase()) ||
tags.contains(_searchQuery.toLowerCase());
final matchesCategory =
_selectedCategoryFilter == null ||
item['categoria'] == _selectedCategoryFilter;
return matchesSearch && matchesCategory;
}).toList();
}
@override
void initState() {
super.initState();
_loadItems();
}
Future<void> _loadItems() async {
setState(() => _isLoading = true);
try {
final user = Supabase.instance.client.auth.currentUser;
if (user == null) return;
final response = await Supabase.instance.client
.from('items')
.select('*, item_images(image_url)')
.eq('user_id', user.id)
.order('id', ascending: false);
if (!mounted) return;
setState(() {
_items = List<Map<String, dynamic>>.from(response);
_isLoading = false;
});
} catch (e) {
debugPrint('Error loading items: $e');
if (mounted) setState(() => _isLoading = false);
}
}
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;
}
Future<void> _deleteItem(Map<String, dynamic> item) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
title: const Text('Apagar item?'),
content: Text('"${item['nome']}" será removido permanentemente.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: AppColors.error),
child: const Text('Apagar'),
),
],
),
);
if (confirmed != true) return;
try {
await Supabase.instance.client
.from('item_images')
.delete()
.eq('item_id', item['id']);
await Supabase.instance.client
.from('items')
.delete()
.eq('id', item['id']);
_loadItems();
if (mounted) AppSnack.success(context, 'Item apagado');
} catch (e) {
if (mounted) AppSnack.error(context, 'Erro: $e');
}
}
void _openItem(Map<String, dynamic> item) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ItemDetailScreen(item: item, imageUrl: _imageUrl(item)),
),
).then((_) => _loadItems());
}
void _editItem(Map<String, dynamic> item) {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => EditItemScreen(item: item)),
).then((_) => _loadItems());
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
body: SafeArea(
bottom: false,
child: Column(
children: [
_buildHeader(),
_buildSearchBar(),
const SizedBox(height: 12),
_buildCategoryChips(),
const SizedBox(height: 16),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _items.isEmpty
? _buildEmpty()
: _filteredItems.isEmpty
? _buildNoResults()
: RefreshIndicator(
onRefresh: _loadItems,
color: AppColors.primary,
child: _gridView ? _buildGrid() : _buildList(),
),
),
],
),
),
floatingActionButton: _buildFab(),
);
}
Widget _buildHeader() {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
child: Row(
children: [
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Inventário', style: AppText.h2),
SizedBox(height: 2),
Text('Os seus itens guardados', style: AppText.caption),
],
),
),
_toggleViewButton(),
],
),
);
}
Widget _toggleViewButton() {
return Container(
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(AppRadius.pill),
),
padding: const EdgeInsets.all(4),
child: Row(
children: [
_viewIcon(Icons.grid_view_rounded, _gridView),
_viewIcon(Icons.view_list_rounded, !_gridView),
],
),
);
}
Widget _viewIcon(IconData icon, bool selected) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(AppRadius.pill),
onTap: () => setState(() => _gridView = !_gridView),
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: selected ? AppColors.surface : Colors.transparent,
borderRadius: BorderRadius.circular(AppRadius.pill),
boxShadow: selected ? AppShadows.soft : null,
),
child: Icon(
icon,
size: 18,
color: selected ? AppColors.primary : AppColors.textSecondary,
),
),
),
);
}
Widget _buildSearchBar() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
decoration: AppDecorations.outlined(radius: AppRadius.pill),
child: TextField(
onChanged: (v) => setState(() => _searchQuery = v),
style: AppText.body,
decoration: InputDecoration(
prefixIcon: const Icon(
Icons.search_rounded,
color: AppColors.textSecondary,
),
hintText: 'Pesquisar por nome ou tag...',
hintStyle: TextStyle(color: AppColors.textTertiary),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
);
}
Widget _buildCategoryChips() {
return SizedBox(
height: 38,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20),
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: AppChip(
label: 'Todos',
icon: Icons.apps_rounded,
selected: _selectedCategoryFilter == null,
onTap: () => setState(() => _selectedCategoryFilter = null),
),
),
...itemCategories.map(
(c) => Padding(
padding: const EdgeInsets.only(right: 8),
child: AppChip(
label: c.name,
icon: c.icon,
color: c.color,
selected: _selectedCategoryFilter == c.id,
onTap: () => setState(() => _selectedCategoryFilter = c.id),
),
),
),
],
),
);
}
Widget _buildEmpty() {
return _emptyState(
icon: Icons.inventory_2_outlined,
title: 'Nenhum item ainda',
subtitle: 'Toque no + para adicionar o primeiro',
);
}
Widget _buildNoResults() {
return _emptyState(
icon: Icons.search_off_rounded,
title: 'Sem resultados',
subtitle: 'Tente outra pesquisa ou categoria',
);
}
Widget _emptyState({
required IconData icon,
required String title,
required String subtitle,
}) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
shape: BoxShape.circle,
),
child: Icon(icon, size: 36, color: AppColors.textTertiary),
),
const SizedBox(height: 16),
Text(title, style: AppText.h3),
const SizedBox(height: 4),
Text(subtitle, style: AppText.caption),
],
),
);
}
Widget _buildGrid() {
return GridView.builder(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 100),
physics: const AlwaysScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.50,
),
itemCount: _filteredItems.length,
itemBuilder: (_, i) => _buildGridCard(_filteredItems[i]),
);
}
Widget _buildList() {
return ListView.builder(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 100),
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _filteredItems.length,
itemBuilder: (_, i) => _buildListCard(_filteredItems[i]),
);
}
Widget _buildGridCard(Map<String, dynamic> item) {
final cat = categoryById(item['categoria'] as String?);
final tags = List<String>.from(item['tags'] ?? []);
final imageUrl = _imageUrl(item);
return Container(
decoration: AppDecorations.card(),
clipBehavior: Clip.antiAlias,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _openItem(item),
onLongPress: () => _showItemActions(item),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: Stack(
fit: StackFit.expand,
children: [
Container(color: cat.color.withValues(alpha: 0.15)),
if (imageUrl != null)
Image.network(
imageUrl,
fit: BoxFit.cover,
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)),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['nome'] ?? 'Sem nome',
maxLines: 1,
overflow: TextOverflow.ellipsis,
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),
Expanded(
child: Text(
cat.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: cat.color,
fontWeight: FontWeight.w600,
),
),
),
],
),
if (tags.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
tags.take(2).join(''),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppText.caption,
),
],
],
),
),
],
),
),
),
);
}
Widget _buildListCard(Map<String, dynamic> item) {
final cat = categoryById(item['categoria'] as String?);
final tags = List<String>.from(item['tags'] ?? []);
final imageUrl = _imageUrl(item);
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: AppDecorations.card(),
clipBehavior: Clip.antiAlias,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _openItem(item),
onLongPress: () => _showItemActions(item),
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.md),
child: Container(
width: 64,
height: 64,
color: cat.color.withValues(alpha: 0.15),
child: imageUrl != null
? Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Icon(cat.icon, color: cat.color, size: 28),
)
: Icon(cat.icon, color: cat.color, size: 28),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['nome'] ?? 'Sem nome',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
color: AppColors.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
Icon(cat.icon, size: 13, color: cat.color),
const SizedBox(width: 4),
Text(
cat.name,
style: TextStyle(
fontSize: 12,
color: cat.color,
fontWeight: FontWeight.w600,
),
),
],
),
if (tags.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
tags.take(3).join(''),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppText.caption,
),
],
],
),
),
_moreButton(item),
],
),
),
),
),
);
}
Widget _moreButton(Map<String, dynamic> item) {
return Material(
color: Colors.white.withValues(alpha: 0.75),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => _showItemActions(item),
child: const Padding(
padding: EdgeInsets.all(6),
child: Icon(
Icons.more_horiz_rounded,
size: 18,
color: AppColors.textPrimary,
),
),
),
);
}
void _showItemActions(Map<String, dynamic> item) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (ctx) => Container(
margin: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 8),
_actionTile(
Icons.visibility_outlined,
'Ver detalhes',
AppColors.primary,
() {
Navigator.pop(ctx);
_openItem(item);
},
),
_actionTile(Icons.edit_outlined, 'Editar', AppColors.primary, () {
Navigator.pop(ctx);
_editItem(item);
}),
_actionTile(
Icons.delete_outline_rounded,
'Apagar',
AppColors.error,
() {
Navigator.pop(ctx);
_deleteItem(item);
},
),
const SizedBox(height: 8),
],
),
),
),
);
}
Widget _actionTile(
IconData icon,
String label,
Color color,
VoidCallback onTap,
) {
return ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Icon(icon, color: color),
),
title: Text(
label,
style: TextStyle(color: color, fontWeight: FontWeight.w600),
),
onTap: onTap,
);
}
Widget _buildFab() {
return Container(
decoration: BoxDecoration(
gradient: AppColors.brandGradient,
borderRadius: BorderRadius.circular(AppRadius.lg),
boxShadow: AppShadows.brand,
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(AppRadius.lg),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AddItemScreen()),
).then((_) => _loadItems());
},
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 18, vertical: 14),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add_rounded, color: Colors.white),
SizedBox(width: 6),
Text(
'Adicionar',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
);
}
}
// ============================================================
// Detalhe do item
// ============================================================
class ItemDetailScreen extends StatelessWidget {
final Map<String, dynamic> item;
final String? imageUrl;
const ItemDetailScreen({super.key, required this.item, this.imageUrl});
@override
Widget build(BuildContext context) {
final cat = categoryById(item['categoria'] as String?);
final tags = List<String>.from(item['tags'] ?? []);
return Scaffold(
backgroundColor: AppColors.background,
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 320,
pinned: true,
backgroundColor: cat.color,
iconTheme: const IconThemeData(color: Colors.white),
actions: [
IconButton(
icon: const Icon(Icons.edit_rounded, color: Colors.white),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => EditItemScreen(item: item),
),
).then((_) {
if (context.mounted) Navigator.pop(context);
});
},
),
],
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
Container(color: cat.color.withValues(alpha: 0.25)),
if (imageUrl != null)
Image.network(
imageUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Center(
child: Icon(cat.icon, color: cat.color, size: 80),
),
)
else
Center(child: Icon(cat.icon, color: cat.color, size: 80)),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.4),
Colors.transparent,
Colors.black.withValues(alpha: 0.2),
],
),
),
),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item['nome'] ?? '', style: AppText.h1),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: cat.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(AppRadius.pill),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(cat.icon, size: 16, color: cat.color),
const SizedBox(width: 6),
Text(
cat.name,
style: TextStyle(
color: cat.color,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(height: 24),
if (tags.isNotEmpty) ...[
const Text('Tags', style: AppText.label),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: tags
.map(
(t) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(
AppRadius.pill,
),
border: Border.all(color: AppColors.border),
),
child: Text(
t,
style: const TextStyle(
color: AppColors.textPrimary,
fontWeight: FontWeight.w500,
),
),
),
)
.toList(),
),
],
],
),
),
),
],
),
);
}
}
// ============================================================
// Editar item
// ============================================================
class EditItemScreen extends StatefulWidget {
final Map<String, dynamic> item;
const EditItemScreen({super.key, required this.item});
@override
State<EditItemScreen> createState() => _EditItemScreenState();
}
class _EditItemScreenState extends State<EditItemScreen> {
late TextEditingController _nameController;
ItemCategory? _selectedCategory;
final Set<String> _selectedTags = {};
bool _isLoading = false;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.item['nome'] ?? '');
final catId = widget.item['categoria'] as String?;
if (catId != null) {
_selectedCategory = categoryById(catId);
}
_selectedTags.addAll(List<String>.from(widget.item['tags'] ?? []));
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
Future<void> _save() async {
if (_nameController.text.trim().isEmpty) {
AppSnack.error(context, 'Nome não pode ser vazio');
return;
}
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']);
if (mounted) {
AppSnack.success(context, 'Item atualizado!');
Navigator.pop(context);
}
} catch (e) {
if (mounted) AppSnack.error(context, 'Erro: $e');
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
backgroundColor: AppColors.background,
elevation: 0,
title: const Text('Editar Item', style: AppText.h3),
iconTheme: const IconThemeData(color: AppColors.textPrimary),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Nome', style: AppText.label),
const SizedBox(height: 8),
Container(
decoration: AppDecorations.outlined(),
child: TextField(
controller: _nameController,
style: AppText.body,
decoration: const InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
hintText: 'Nome do item',
),
),
),
const SizedBox(height: 20),
const Text('Categoria', style: AppText.label),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: itemCategories
.map(
(c) => AppChip(
label: c.name,
icon: c.icon,
color: c.color,
selected: _selectedCategory?.id == c.id,
onTap: () => setState(() => _selectedCategory = c),
),
)
.toList(),
),
const SizedBox(height: 20),
const Text('Tags de contexto', style: AppText.label),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: contextTags.map((tag) {
final selected = _selectedTags.contains(tag.id);
return AppChip(
label: tag.name,
selected: selected,
onTap: () {
setState(() {
if (selected) {
_selectedTags.remove(tag.id);
} else {
_selectedTags.add(tag.id);
}
});
},
);
}).toList(),
),
const SizedBox(height: 32),
AppButton(
label: 'Guardar alterações',
icon: Icons.check_rounded,
loading: _isLoading,
onPressed: _save,
),
],
),
),
);
}
}