Troca de desing

This commit is contained in:
Carlos Correia
2026-05-18 15:04:07 +01:00
parent 9b4c2f7e04
commit 9999011cfd
9 changed files with 3000 additions and 2138 deletions

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../constants/item_categories.dart';
import '../theme/app_theme.dart';
class WeekScreen extends StatefulWidget {
const WeekScreen({super.key});
@@ -14,17 +15,8 @@ class _WeekScreenState extends State<WeekScreen> {
List<Map<String, dynamic>> _dayItems = [];
bool _isLoading = false;
static const _weekdayNames = [
'Seg',
'Ter',
'Qua',
'Qui',
'Sex',
'Sáb',
'Dom',
];
static const _weekdayNamesLong = [
static const _weekdayShort = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom'];
static const _weekdayLong = [
'Segunda',
'Terça',
'Quarta',
@@ -42,11 +34,8 @@ class _WeekScreenState extends State<WeekScreen> {
DateTime get _startOfWeek {
final now = DateTime.now();
return DateTime(
now.year,
now.month,
now.day,
).subtract(Duration(days: now.weekday - 1));
return DateTime(now.year, now.month, now.day)
.subtract(Duration(days: now.weekday - 1));
}
String _dateKey(DateTime d) =>
@@ -57,16 +46,13 @@ class _WeekScreenState extends State<WeekScreen> {
Future<int> _getOrCreatePlanId(DateTime day) async {
final user = Supabase.instance.client.auth.currentUser!;
final dateStr = _dateKey(day);
final existing = await Supabase.instance.client
.from('plans')
.select('id')
.eq('user_id', user.id)
.eq('data', dateStr)
.maybeSingle();
if (existing != null) return existing['id'] as int;
final created = await Supabase.instance.client
.from('plans')
.insert({'user_id': user.id, 'data': dateStr})
@@ -80,7 +66,6 @@ class _WeekScreenState extends State<WeekScreen> {
try {
final user = Supabase.instance.client.auth.currentUser;
if (user == null) return;
final dateStr = _dateKey(_selectedDay);
final plan = await Supabase.instance.client
.from('plans')
@@ -88,15 +73,14 @@ class _WeekScreenState extends State<WeekScreen> {
.eq('user_id', user.id)
.eq('data', dateStr)
.maybeSingle();
if (plan == null) {
if (!mounted) return;
setState(() {
_dayItems = [];
_isLoading = false;
});
return;
}
final planItems = plan['plan_items'] as List? ?? [];
final items = planItems
.where((pi) => pi['items'] != null)
@@ -104,55 +88,46 @@ class _WeekScreenState extends State<WeekScreen> {
(pi) => Map<String, dynamic>.from(pi['items']),
)
.toList();
if (!mounted) return;
setState(() {
_dayItems = items;
_isLoading = false;
});
} catch (e) {
debugPrint('Error loading day items: $e');
setState(() => _isLoading = false);
debugPrint('Error loading day: $e');
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _addItemsToDay() async {
final user = Supabase.instance.client.auth.currentUser;
if (user == null) return;
final allItems = await Supabase.instance.client
.from('items')
.select('*, item_images(image_url)')
.eq('user_id', user.id);
final available = List<Map<String, dynamic>>.from(allItems);
final existingIds = _dayItems.map((i) => i['id']).toSet();
final toShow = available
.where((i) => !existingIds.contains(i['id']))
.toList();
final toShow =
available.where((i) => !existingIds.contains(i['id'])).toList();
if (!mounted) return;
final selected = await showModalBottomSheet<List<int>>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => _ItemPickerSheet(items: toShow),
builder: (_) => _ItemPickerSheet(items: toShow),
);
if (selected == null || selected.isEmpty) return;
try {
final planId = await _getOrCreatePlanId(_selectedDay);
final rows = selected
.map((id) => {'plan_id': planId, 'item_id': id})
.toList();
final rows =
selected.map((id) => {'plan_id': planId, 'item_id': id}).toList();
await Supabase.instance.client.from('plan_items').insert(rows);
_loadDayItems();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erro: $e'), backgroundColor: Colors.red),
);
}
if (mounted) AppSnack.error(context, 'Erro: $e');
}
}
@@ -166,166 +141,192 @@ class _WeekScreenState extends State<WeekScreen> {
.eq('item_id', item['id']);
_loadDayItems();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erro: $e'), backgroundColor: Colors.red),
);
}
if (mounted) AppSnack.error(context, 'Erro: $e');
}
}
@override
Widget build(BuildContext context) {
final start = _startOfWeek;
final days = List.generate(7, (i) => start.add(Duration(days: i)));
final days = List.generate(7, (i) => _startOfWeek.add(Duration(days: i)));
return Scaffold(
backgroundColor: const Color(0xFFFFE5CC),
appBar: AppBar(
backgroundColor: const Color(0xFF0066CC),
elevation: 0,
title: const Text(
'Minha Semana',
style: TextStyle(color: Colors.white, fontSize: 20),
backgroundColor: AppColors.background,
body: SafeArea(
bottom: false,
child: Column(
children: [
_buildHeader(),
_buildDaysRow(days),
const SizedBox(height: 12),
_buildDayTitle(),
const SizedBox(height: 8),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _dayItems.isEmpty
? _buildEmpty()
: ListView.builder(
padding: const EdgeInsets.fromLTRB(
20, 0, 20, 120,
),
itemCount: _dayItems.length,
itemBuilder: (_, i) =>
_buildItemTile(_dayItems[i]),
),
),
],
),
centerTitle: true,
),
body: Column(
floatingActionButton: _buildFab(),
);
}
Widget _buildHeader() {
return const Padding(
padding: EdgeInsets.fromLTRB(20, 12, 20, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Week selector
Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
child: Row(
children: days.map((day) {
final isSelected =
day.year == _selectedDay.year &&
day.month == _selectedDay.month &&
day.day == _selectedDay.day;
final isToday =
day.year == DateTime.now().year &&
day.month == DateTime.now().month &&
day.day == DateTime.now().day;
return Expanded(
child: GestureDetector(
onTap: () {
setState(() => _selectedDay = day);
_loadDayItems();
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 3),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFF0066CC)
: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isToday
? const Color(0xFF0066CC)
: Colors.transparent,
width: 2,
),
),
child: Column(
children: [
Text(
_weekdayNames[day.weekday - 1],
style: TextStyle(
fontSize: 12,
color: isSelected
? Colors.white
: const Color(0xFF666666),
),
),
const SizedBox(height: 4),
Text(
'${day.day}',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: isSelected
? Colors.white
: const Color(0xFF333333),
),
),
],
Text('Minha Semana', style: AppText.h2),
SizedBox(height: 2),
Text(
'Planeie o que precisa para cada dia',
style: AppText.caption,
),
],
),
);
}
Widget _buildDaysRow(List<DateTime> days) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: days.map((day) {
final isSelected = day.year == _selectedDay.year &&
day.month == _selectedDay.month &&
day.day == _selectedDay.day;
final today = DateTime.now();
final isToday = day.year == today.year &&
day.month == today.month &&
day.day == today.day;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(AppRadius.md),
onTap: () {
setState(() => _selectedDay = day);
_loadDayItems();
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
gradient: isSelected ? AppColors.brandGradient : null,
color: isSelected ? null : AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(
color: isToday && !isSelected
? AppColors.primary
: AppColors.border,
width: isToday ? 1.5 : 1,
),
boxShadow: isSelected ? AppShadows.brand : null,
),
),
);
}).toList(),
),
),
// Day title
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 12),
child: Row(
children: [
Text(
'${_weekdayNamesLong[_selectedDay.weekday - 1]}, '
'${_selectedDay.day}/${_selectedDay.month}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
const Spacer(),
Text(
'${_dayItems.length} ${_dayItems.length == 1 ? "item" : "itens"}',
style: const TextStyle(
color: Color(0xFF666666),
fontSize: 13,
),
),
],
),
),
// Items list
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _dayItems.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.calendar_today_outlined,
size: 56,
color: Colors.grey[400],
),
const SizedBox(height: 12),
const Text(
'Nenhum item para este dia',
Text(
_weekdayShort[day.weekday - 1],
style: TextStyle(
color: Color(0xFF666666),
fontSize: 16,
fontSize: 11,
fontWeight: FontWeight.w600,
color: isSelected
? Colors.white
: AppColors.textSecondary,
),
),
const SizedBox(height: 4),
const Text(
'Toque em + para adicionar',
Text(
'${day.day}',
style: TextStyle(
color: Color(0xFF999999),
fontSize: 13,
fontSize: 18,
fontWeight: FontWeight.bold,
color: isSelected
? Colors.white
: AppColors.textPrimary,
),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _dayItems.length,
itemBuilder: (_, i) => _buildItemTile(_dayItems[i]),
),
),
),
),
);
}).toList(),
),
);
}
Widget _buildDayTitle() {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Row(
children: [
Text(
'${_weekdayLong[_selectedDay.weekday - 1]}, '
'${_selectedDay.day}/${_selectedDay.month}',
style: AppText.h3,
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppRadius.pill),
),
child: Text(
'${_dayItems.length} ${_dayItems.length == 1 ? "item" : "itens"}',
style: const TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
],
),
floatingActionButton: FloatingActionButton(
backgroundColor: const Color(0xFF0066CC),
onPressed: _addItemsToDay,
child: const Icon(Icons.add, color: Colors.white),
);
}
Widget _buildEmpty() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.event_available_rounded,
color: AppColors.primary,
size: 36,
),
),
const SizedBox(height: 16),
const Text('Nada planeado', style: AppText.h3),
const SizedBox(height: 4),
Text(
'Toque em + para adicionar itens',
style: AppText.caption,
),
],
),
);
}
@@ -333,58 +334,132 @@ class _WeekScreenState extends State<WeekScreen> {
Widget _buildItemTile(Map<String, dynamic> item) {
final images = item['item_images'] as List?;
final imageUrl = (images != null && images.isNotEmpty)
? images.first['image_url']
? images.first['image_url'] as String?
: null;
final category = itemCategories.firstWhere(
(c) => c.id == item['categoria'],
orElse: () => itemCategories.last,
);
return Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: imageUrl != null
? Image.network(
imageUrl,
final cat = categoryById(item['categoria'] as String?);
return Dismissible(
key: ValueKey(item['id']),
direction: DismissDirection.endToStart,
background: Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 24),
alignment: Alignment.centerRight,
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: const Icon(Icons.delete_outline, color: Colors.white),
),
onDismissed: (_) => _removeItem(item),
child: Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: AppDecorations.card(),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.md),
child: Container(
width: 56,
height: 56,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
_icon(category.icon),
)
: _icon(category.icon),
),
title: Text(
item['nome'] ?? 'Sem nome',
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(category.name),
trailing: IconButton(
icon: const Icon(Icons.close, color: Colors.red),
onPressed: () => _removeItem(item),
color: cat.color.withValues(alpha: 0.15),
child: imageUrl != null
? Image.network(
imageUrl,
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: 15,
color: AppColors.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
Icon(cat.icon, size: 12, color: cat.color),
const SizedBox(width: 4),
Text(
cat.name,
style: TextStyle(
fontSize: 12,
color: cat.color,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
),
IconButton(
icon: const Icon(
Icons.close_rounded,
color: AppColors.textTertiary,
),
onPressed: () => _removeItem(item),
),
],
),
),
),
);
}
Widget _icon(String icon) {
Widget _buildFab() {
return Container(
width: 56,
height: 56,
color: const Color(0xFF0066CC).withValues(alpha: 0.1),
alignment: Alignment.center,
child: Text(icon, style: const TextStyle(fontSize: 26)),
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: _addItemsToDay,
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,
),
),
],
),
),
),
),
);
}
}
// =============================================
// Bottom sheet para escolher itens
// =============================================
// ============================================================
// Item picker bottom sheet
// ============================================================
class _ItemPickerSheet extends StatefulWidget {
final List<Map<String, dynamic>> items;
const _ItemPickerSheet({required this.items});
@@ -401,9 +476,10 @@ class _ItemPickerSheetState extends State<_ItemPickerSheet> {
Widget build(BuildContext context) {
final filtered = widget.items
.where(
(i) => (i['nome'] ?? '').toString().toLowerCase().contains(
_query.toLowerCase(),
),
(i) => (i['nome'] ?? '')
.toString()
.toLowerCase()
.contains(_query.toLowerCase()),
)
.toList();
@@ -414,132 +490,74 @@ class _ItemPickerSheetState extends State<_ItemPickerSheet> {
expand: false,
builder: (_, scrollController) => Container(
decoration: const BoxDecoration(
color: Color(0xFFFFE5CC),
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
color: AppColors.background,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
children: [
Container(
margin: const EdgeInsets.symmetric(vertical: 8),
width: 40,
margin: const EdgeInsets.symmetric(vertical: 10),
width: 42,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[400],
color: AppColors.border,
borderRadius: BorderRadius.circular(2),
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(
'Adicionar itens ao dia',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text('Adicionar itens ao dia', style: AppText.h3),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 8),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
),
decoration: AppDecorations.outlined(radius: AppRadius.pill),
child: TextField(
onChanged: (v) => setState(() => _query = v),
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search),
prefixIcon: Icon(
Icons.search_rounded,
color: AppColors.textSecondary,
),
hintText: 'Pesquisar...',
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(vertical: 12),
contentPadding: EdgeInsets.symmetric(vertical: 14),
),
),
),
),
Expanded(
child: filtered.isEmpty
? const Center(child: Text('Nenhum item disponível'))
? Center(
child: Text(
'Sem itens disponíveis',
style: AppText.caption,
),
)
: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
itemCount: filtered.length,
itemBuilder: (_, i) {
final item = filtered[i];
final id = item['id'] as int;
final selected = _selected.contains(id);
final images = item['item_images'] as List?;
final imageUrl = (images != null && images.isNotEmpty)
? images.first['image_url']
: null;
final category = itemCategories.firstWhere(
(c) => c.id == item['categoria'],
orElse: () => itemCategories.last,
);
return CheckboxListTile(
value: selected,
activeColor: const Color(0xFF0066CC),
onChanged: (v) {
setState(() {
if (v == true) {
_selected.add(id);
} else {
_selected.remove(id);
}
});
},
title: Text(item['nome'] ?? ''),
subtitle: Text(category.name),
secondary: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: imageUrl != null
? Image.network(
imageUrl,
width: 48,
height: 48,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) =>
Container(
width: 48,
height: 48,
color: const Color(
0xFF0066CC,
).withValues(alpha: 0.1),
alignment: Alignment.center,
child: Text(category.icon),
),
)
: Container(
width: 48,
height: 48,
color: const Color(
0xFF0066CC,
).withValues(alpha: 0.1),
alignment: Alignment.center,
child: Text(category.icon),
),
),
);
},
itemBuilder: (_, i) =>
_buildPickerTile(filtered[i]),
),
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _selected.isEmpty
? null
: () => Navigator.pop(context, _selected.toList()),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0066CC),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: Text(
'Adicionar (${_selected.length})',
style: const TextStyle(color: Colors.white, fontSize: 16),
),
),
padding: const EdgeInsets.all(20),
child: AppButton(
label: _selected.isEmpty
? 'Selecione itens'
: 'Adicionar (${_selected.length})',
icon: Icons.check_rounded,
onPressed: _selected.isEmpty
? null
: () =>
Navigator.pop(context, _selected.toList()),
),
),
),
@@ -548,4 +566,113 @@ class _ItemPickerSheetState extends State<_ItemPickerSheet> {
),
);
}
Widget _buildPickerTile(Map<String, dynamic> item) {
final id = item['id'] as int;
final selected = _selected.contains(id);
final images = item['item_images'] as List?;
final imageUrl = (images != null && images.isNotEmpty)
? images.first['image_url'] as String?
: null;
final cat = categoryById(item['categoria'] as String?);
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(
color: selected ? AppColors.primary : AppColors.border,
width: selected ? 1.5 : 1,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(AppRadius.md),
onTap: () {
setState(() {
if (selected) {
_selected.remove(id);
} else {
_selected.add(id);
}
});
},
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Container(
width: 48,
height: 48,
color: cat.color.withValues(alpha: 0.15),
child: imageUrl != null
? Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Icon(cat.icon, color: cat.color, size: 22),
)
: Icon(cat.icon, color: cat.color, size: 22),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['nome'] ?? '',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
cat.name,
style: TextStyle(
fontSize: 12,
color: cat.color,
fontWeight: FontWeight.w600,
),
),
],
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 24,
height: 24,
decoration: BoxDecoration(
color: selected
? AppColors.primary
: Colors.transparent,
shape: BoxShape.circle,
border: Border.all(
color: selected
? AppColors.primary
: AppColors.border,
width: 2,
),
),
child: selected
? const Icon(
Icons.check_rounded,
color: Colors.white,
size: 16,
)
: null,
),
],
),
),
),
),
);
}
}