Agrega PRIVACY.md, actualiza AGENTS.md

This commit is contained in:
Super User
2026-06-14 15:34:53 -04:00
parent 9ac9e9e953
commit 0df1bf90cf
20 changed files with 685 additions and 166 deletions

View File

@@ -4,6 +4,7 @@ import 'providers/servicio_provider.dart';
import 'providers/factura_provider.dart';
import 'providers/configuracion_provider.dart';
import 'screens/splash_screen.dart';
import 'utils/transitions.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
@@ -25,14 +26,88 @@ class HogarControlApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'HogarControl',
title: 'Factura del Hogar',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.teal,
useMaterial3: true,
brightness: Brightness.light,
),
theme: _buildLightTheme(),
darkTheme: _buildDarkTheme(),
themeMode: ThemeMode.system,
home: const SplashScreen(),
);
}
ThemeData _buildLightTheme() {
final colorScheme = ColorScheme.fromSeed(
seedColor: Colors.teal,
brightness: Brightness.light,
);
return ThemeData(
colorScheme: colorScheme,
useMaterial3: true,
cardTheme: CardTheme(
elevation: 1,
shadowColor: colorScheme.shadow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
clipBehavior: Clip.antiAlias,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: SmoothPageTransitionBuilder(),
TargetPlatform.iOS: SmoothPageTransitionBuilder(),
},
),
textTheme: const TextTheme(
titleLarge: TextStyle(fontWeight: FontWeight.w600),
titleMedium: TextStyle(fontWeight: FontWeight.w600),
),
);
}
ThemeData _buildDarkTheme() {
final colorScheme = ColorScheme.fromSeed(
seedColor: Colors.teal,
brightness: Brightness.dark,
);
return ThemeData(
colorScheme: colorScheme,
useMaterial3: true,
cardTheme: CardTheme(
elevation: 1,
shadowColor: colorScheme.shadow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
clipBehavior: Clip.antiAlias,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: SmoothPageTransitionBuilder(),
TargetPlatform.iOS: SmoothPageTransitionBuilder(),
},
),
);
}
}

View File

@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import 'package:table_calendar/table_calendar.dart';
import '../providers/factura_provider.dart';
import '../providers/servicio_provider.dart';
import '../providers/configuracion_provider.dart';
import '../utils/currency_utils.dart';
class CalendarioScreen extends StatefulWidget {
const CalendarioScreen({super.key});
@@ -54,7 +54,7 @@ class _CalendarioScreenState extends State<CalendarioScreen> {
context.watch<FacturaProvider>();
context.watch<ServicioProvider>();
final configProv = context.watch<ConfiguracionProvider>();
final symbol = configProv.config?.moneda == 'USD' ? '\$' : configProv.config?.moneda == 'EUR' ? '' : 'MX\$';
final moneda = configProv.config?.moneda ?? 'USD';
return Scaffold(
appBar: AppBar(title: const Text('Calendario de pagos')),
@@ -103,17 +103,41 @@ class _CalendarioScreenState extends State<CalendarioScreen> {
Expanded(
child: _selectedDay == null || (_eventos[DateTime(_selectedDay!.year, _selectedDay!.month, _selectedDay!.day)]?.isEmpty ?? true)
? Center(child: Text('Sin facturas en esta fecha', style: Theme.of(context).textTheme.bodyLarge))
: ListView(
children: (_eventos[DateTime(_selectedDay!.year, _selectedDay!.month, _selectedDay!.day)] ?? []).map((e) {
return Card(
child: ListTile(
leading: CircleAvatar(backgroundColor: e['color'] as Color, child: Text((e['servicio'] as String).substring(0, 2).toUpperCase(), style: const TextStyle(color: Colors.white, fontSize: 12))),
title: Text(e['servicio'] as String),
subtitle: Text('$symbol${NumberFormat('#,##0.00').format(e['monto'])}'),
trailing: Chip(label: Text(e['estado'] as String, style: TextStyle(fontSize: 12, color: e['estado'] == 'Pagada' ? Colors.green : Colors.orange))),
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: (_eventos[DateTime(_selectedDay!.year, _selectedDay!.month, _selectedDay!.day)] ?? []).length,
itemBuilder: (_, i) {
final e = _eventos[DateTime(_selectedDay!.year, _selectedDay!.month, _selectedDay!.day)]![i];
final esPagada = e['estado'] == 'Pagada';
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: Duration(milliseconds: 250 + i * 80),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(0, 16 * (1 - value)),
child: child,
),
);
},
child: Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(backgroundColor: e['color'] as Color, child: Text((e['servicio'] as String).substring(0, 2).toUpperCase(), style: const TextStyle(color: Colors.white, fontSize: 12))),
title: Text(e['servicio'] as String, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(CurrencyUtils.formatPrice((e['monto'] as num).toDouble(), moneda)),
trailing: Chip(
label: Text(e['estado'] as String, style: TextStyle(fontSize: 12, color: esPagada ? Colors.green : Colors.orange)),
backgroundColor: esPagada ? Colors.green.withValues(alpha: 0.12) : Colors.orange.withValues(alpha: 0.12),
side: BorderSide.none,
padding: const EdgeInsets.symmetric(horizontal: 4),
),
),
),
);
}).toList(),
},
),
),
],

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/configuracion.dart';
import '../providers/configuracion_provider.dart';
import '../utils/currency_utils.dart';
class ConfiguracionScreen extends StatefulWidget {
const ConfiguracionScreen({super.key});
@@ -21,7 +22,7 @@ class _ConfiguracionScreenState extends State<ConfiguracionScreen> {
{'label': 'Día de vencimiento', 'value': 0},
];
final List<String> _monedas = ['USD', 'EUR', 'MXN', 'COP', 'ARS', 'PEN', 'CLP', 'BRL'];
final List<CurrencyInfo> _monedas = CurrencyUtils.allCurrencies;
@override
void initState() {
@@ -59,7 +60,7 @@ class _ConfiguracionScreenState extends State<ConfiguracionScreen> {
DropdownButtonFormField<String>(
value: _moneda,
decoration: const InputDecoration(border: OutlineInputBorder()),
items: _monedas.map((m) => DropdownMenuItem(value: m, child: Text(m))).toList(),
items: _monedas.map((m) => DropdownMenuItem(value: m.code, child: Text(CurrencyUtils.getDisplayName(m.code)))).toList(),
onChanged: (v) => setState(() => _moneda = v!),
),
const SizedBox(height: 32),

View File

@@ -4,6 +4,8 @@ import 'package:intl/intl.dart';
import '../models/factura.dart';
import '../providers/factura_provider.dart';
import '../providers/servicio_provider.dart';
import '../providers/configuracion_provider.dart';
import '../utils/currency_utils.dart';
class CrearFacturaScreen extends StatefulWidget {
const CrearFacturaScreen({super.key});
@@ -40,6 +42,9 @@ class _CrearFacturaScreenState extends State<CrearFacturaScreen> {
Widget build(BuildContext context) {
final servicioProv = context.watch<ServicioProvider>();
final facturaProv = context.read<FacturaProvider>();
final configProv = context.watch<ConfiguracionProvider>();
final moneda = configProv.config?.moneda ?? 'USD';
final symbol = CurrencyUtils.getSymbol(moneda);
return Scaffold(
appBar: AppBar(title: const Text('Nueva factura')),
@@ -58,7 +63,7 @@ class _CrearFacturaScreenState extends State<CrearFacturaScreen> {
const SizedBox(height: 16),
TextFormField(
controller: _montoCtrl,
decoration: const InputDecoration(labelText: 'Monto', border: OutlineInputBorder(), prefixText: '\$ '),
decoration: InputDecoration(labelText: 'Monto', border: const OutlineInputBorder(), prefixText: '$symbol '),
keyboardType: TextInputType.numberWithOptions(decimal: true),
validator: (v) {
if (v == null || v.isEmpty) return 'Ingresa el monto';

View File

@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../providers/servicio_provider.dart';
import '../providers/factura_provider.dart';
import '../providers/configuracion_provider.dart';
import '../widgets/stat_card.dart';
import '../utils/currency_utils.dart';
import 'servicios_screen.dart';
import 'facturas_screen.dart';
import 'calendario_screen.dart';
@@ -37,13 +37,12 @@ class _DashboardScreenState extends State<DashboardScreen> {
final servicioProv = context.watch<ServicioProvider>();
final configProv = context.watch<ConfiguracionProvider>();
final moneda = configProv.config?.moneda ?? 'USD';
final symbol = moneda == 'USD' ? '\$' : moneda == 'EUR' ? '' : moneda == 'MXN' ? 'MX\$' : '\$';
final pendientes = facturaProv.facturas.where((f) => f.estado == 'Pendiente').length;
final vencidas = facturaProv.facturasVencidas.length;
return Scaffold(
appBar: AppBar(title: const Text('HogarControl'), centerTitle: true, actions: [
appBar: AppBar(title: const Text('Factura del Hogar'), centerTitle: true, actions: [
IconButton(icon: const Icon(Icons.settings), onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ConfiguracionScreen()))),
]),
body: RefreshIndicator(
@@ -58,6 +57,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
value: '$pendientes',
icon: Icons.pending_actions,
color: Colors.orange,
delayMs: 50,
)),
const SizedBox(width: 12),
Expanded(child: StatCard(
@@ -65,6 +65,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
value: '$vencidas',
icon: Icons.warning_rounded,
color: Colors.red,
delayMs: 100,
)),
],
),
@@ -76,36 +77,74 @@ class _DashboardScreenState extends State<DashboardScreen> {
value: facturaProv.facturas.where((f) => f.estado == 'Pagada').length.toString(),
icon: Icons.check_circle,
color: Colors.green,
delayMs: 150,
)),
const SizedBox(width: 12),
Expanded(child: StatCard(
title: 'Total gastado',
value: '$symbol${NumberFormat('#,##0.00').format(facturaProv.facturas.where((f) => f.estado == 'Pagada').fold(0.0, (sum, f) => sum + f.monto))}',
value: CurrencyUtils.formatPrice(facturaProv.facturas.where((f) => f.estado == 'Pagada').fold(0.0, (sum, f) => sum + f.monto), moneda),
icon: Icons.attach_money,
color: Colors.blue,
delayMs: 200,
)),
],
),
const SizedBox(height: 20),
Text('Próximos vencimientos', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
const SizedBox(height: 24),
Row(
children: [
Icon(Icons.event_note, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Text('Próximos vencimientos', style: Theme.of(context).textTheme.titleMedium),
],
),
const SizedBox(height: 12),
...facturaProv.facturas
.where((f) => f.estado == 'Pendiente')
.take(5)
.map((f) {
.toList()
.asMap()
.entries
.map((entry) {
final i = entry.key;
final f = entry.value;
final servicio = servicioProv.servicios.where((s) => s.id == f.servicioId).firstOrNull;
return Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: Color(servicio?.color ?? 0xFF6200EE),
child: Text(servicio?.nombre.substring(0, 2).toUpperCase() ?? '??',
style: const TextStyle(color: Colors.white, fontSize: 12)),
),
title: Text(servicio?.nombre ?? 'Desconocido'),
subtitle: Text('Ven: ${f.fechaVencimiento} $symbol${NumberFormat('#,##0.00').format(f.monto)}'),
trailing: TextButton(
onPressed: () => facturaProv.marcarPagada(f.id!),
child: const Text('Pagar'),
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: Duration(milliseconds: 350 + i * 80),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(0, 16 * (1 - value)),
child: child,
),
);
},
child: Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: Color(servicio?.color ?? 0xFF6200EE),
child: Text(servicio?.nombre.substring(0, 2).toUpperCase() ?? '??',
style: const TextStyle(color: Colors.white, fontSize: 12)),
),
title: Text(servicio?.nombre ?? 'Desconocido'),
subtitle: Text('Vence: ${f.fechaVencimiento}'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(CurrencyUtils.formatPrice(f.monto, moneda),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary)),
const SizedBox(width: 4),
IconButton(
icon: Icon(Icons.check_circle_outline, color: Theme.of(context).colorScheme.primary),
onPressed: () => facturaProv.marcarPagada(f.id!),
tooltip: 'Pagar',
),
],
),
),
),
);

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import 'package:fl_chart/fl_chart.dart';
import '../providers/factura_provider.dart';
import '../providers/configuracion_provider.dart';
import '../utils/currency_utils.dart';
class EstadisticasScreen extends StatefulWidget {
const EstadisticasScreen({super.key});
@@ -26,7 +26,6 @@ class _EstadisticasScreenState extends State<EstadisticasScreen> {
final facturaProv = context.watch<FacturaProvider>();
final configProv = context.watch<ConfiguracionProvider>();
final moneda = configProv.config?.moneda ?? 'USD';
final symbol = moneda == 'USD' ? '\$' : moneda == 'EUR' ? '' : moneda == 'MXN' ? 'MX\$' : '\$';
return Scaffold(
appBar: AppBar(title: const Text('Estadísticas')),
@@ -48,19 +47,33 @@ class _EstadisticasScreenState extends State<EstadisticasScreen> {
children: [
Text('Resumen $_year', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Gasto mensual actual', style: Theme.of(context).textTheme.titleSmall),
Text('$symbol${NumberFormat('#,##0.00').format(totalMes)}',
style: Theme.of(context).textTheme.headlineMedium),
],
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.attach_money, color: Theme.of(context).colorScheme.primary, size: 28),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Gasto mensual actual', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Text(CurrencyUtils.formatPrice(totalMes, moneda),
style: Theme.of(context).textTheme.headlineMedium),
],
),
],
),
),
),
),
),
const SizedBox(height: 20),
Text('Gasto mensual ($_year)', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
@@ -86,7 +99,7 @@ class _EstadisticasScreenState extends State<EstadisticasScreen> {
);
}),
titlesData: FlTitlesData(
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true, reservedSize: 40, getTitlesWidget: (v, _) => Text('$symbol${v.toInt()}', style: const TextStyle(fontSize: 10)))),
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true, reservedSize: 40, getTitlesWidget: (v, _) => Text('${CurrencyUtils.getSymbol(moneda)}${v.toInt()}', style: const TextStyle(fontSize: 10)))),
bottomTitles: AxisTitles(sideTitles: SideTitles(showTitles: true, getTitlesWidget: (v, _) {
const meses = ['E', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D'];
return Text(meses[v.toInt()], style: const TextStyle(fontSize: 10));
@@ -109,27 +122,43 @@ class _EstadisticasScreenState extends State<EstadisticasScreen> {
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: gastoPorServicio.entries.map((e) {
children: gastoPorServicio.entries.toList().asMap().entries.map((entry) {
final i = entry.key;
final e = entry.value;
final total = gastoPorServicio.values.fold(0.0, (a, b) => a + b);
final pct = total > 0 ? e.value / total * 100 : 0.0;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
SizedBox(width: 100, child: Text(e.key, style: const TextStyle(fontSize: 13))),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: pct / 100,
backgroundColor: Colors.grey[200],
minHeight: 12,
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: Duration(milliseconds: 300 + i * 80),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(0, 12 * (1 - value)),
child: child,
),
);
},
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
SizedBox(width: 100, child: Text(e.key, style: const TextStyle(fontSize: 13))),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: pct / 100,
backgroundColor: Colors.grey[200],
minHeight: 12,
),
),
),
),
const SizedBox(width: 8),
SizedBox(width: 70, child: Text('$symbol${NumberFormat('#,##0.00').format(e.value)}', style: const TextStyle(fontSize: 12), textAlign: TextAlign.right)),
],
const SizedBox(width: 8),
SizedBox(width: 70, child: Text(CurrencyUtils.formatPrice(e.value, moneda), style: const TextStyle(fontSize: 12), textAlign: TextAlign.right)),
],
),
),
);
}).toList(),

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../providers/factura_provider.dart';
import '../providers/servicio_provider.dart';
import '../providers/configuracion_provider.dart';
import '../utils/currency_utils.dart';
class FacturasScreen extends StatefulWidget {
const FacturasScreen({super.key});
@@ -28,7 +28,6 @@ class _FacturasScreenState extends State<FacturasScreen> {
final servicioProv = context.watch<ServicioProvider>();
final configProv = context.watch<ConfiguracionProvider>();
final moneda = configProv.config?.moneda ?? 'USD';
final symbol = moneda == 'USD' ? '\$' : moneda == 'EUR' ? '' : moneda == 'MXN' ? 'MX\$' : '\$';
return Scaffold(
appBar: AppBar(title: const Text('Facturas')),
@@ -75,28 +74,44 @@ class _FacturasScreenState extends State<FacturasScreen> {
final f = facturaProv.facturas[i];
final servicio = servicioProv.servicios.where((s) => s.id == f.servicioId).firstOrNull;
final vencida = f.estado == 'Pendiente' && DateTime.tryParse(f.fechaVencimiento)?.isBefore(DateTime.now()) == true;
return Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: Color(servicio?.color ?? 0xFF6200EE),
child: Text(servicio?.nombre.substring(0, 2).toUpperCase() ?? '??',
style: const TextStyle(color: Colors.white, fontSize: 12)),
),
title: Text('${servicio?.nombre ?? 'Desconocido'} - $symbol${NumberFormat('#,##0.00').format(f.monto)}'),
subtitle: Text('Vence: ${f.fechaVencimiento}${f.estado == 'Pagada' ? ' Pagada: ${f.fechaPago ?? ''}' : ''}'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (f.estado == 'Pendiente')
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: Duration(milliseconds: 300 + i * 50),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(0, 16 * (1 - value)),
child: child,
),
);
},
child: Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Color(servicio?.color ?? 0xFF6200EE),
child: Text(servicio?.nombre.substring(0, 2).toUpperCase() ?? '??',
style: const TextStyle(color: Colors.white, fontSize: 12)),
),
title: Text('${servicio?.nombre ?? 'Desconocido'} - ${CurrencyUtils.formatPrice(f.monto, moneda)}',
style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text('Vence: ${f.fechaVencimiento}${f.estado == 'Pagada' ? ' Pagada: ${f.fechaPago ?? ''}' : ''}'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (f.estado == 'Pendiente')
IconButton(
icon: Icon(Icons.check_circle, color: vencida ? Colors.red : Colors.green),
onPressed: () => facturaProv.marcarPagada(f.id!),
),
IconButton(
icon: Icon(Icons.check_circle, color: vencida ? Colors.red : Colors.green),
onPressed: () => facturaProv.marcarPagada(f.id!),
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => facturaProv.eliminarFactura(f.id!),
),
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => facturaProv.eliminarFactura(f.id!),
),
],
],
),
),
),
);

View File

@@ -32,35 +32,50 @@ class ServiciosScreen extends StatelessWidget {
itemCount: provider.servicios.length,
itemBuilder: (_, i) {
final s = provider.servicios[i];
return Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: Color(s.color),
child: Icon(_getIcon(s.icono), color: Colors.white),
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: Duration(milliseconds: 300 + i * 60),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(0, 20 * (1 - value)),
child: child,
),
);
},
child: Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Color(s.color),
child: Icon(_getIcon(s.icono), color: Colors.white),
),
title: Text(s.nombre, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text('Vence día ${s.diaVencimiento}'),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Eliminar servicio'),
content: Text('¿Eliminar ${s.nombre}? Se eliminarán sus facturas.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancelar')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Eliminar')),
],
),
);
if (ok == true) provider.eliminarServicio(s.id!);
},
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => CrearServicioScreen(servicio: s)),
).then((_) => provider.cargarServicios()),
),
title: Text(s.nombre),
subtitle: Text('Vence día ${s.diaVencimiento}'),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Eliminar servicio'),
content: Text('¿Eliminar ${s.nombre}? Se eliminarán sus facturas.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancelar')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Eliminar')),
],
),
);
if (ok == true) provider.eliminarServicio(s.id!);
},
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => CrearServicioScreen(servicio: s)),
).then((_) => provider.cargarServicios()),
),
);
},

View File

@@ -11,10 +11,39 @@ class SplashScreen extends StatefulWidget {
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnim;
late Animation<double> _fadeAnim;
late Animation<Offset> _slideAnim;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_scaleAnim = Tween<double>(begin: 0.5, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.elasticOut),
);
_fadeAnim = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.3, 0.7, curve: Curves.easeIn),
),
);
_slideAnim = Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.4, 0.8, curve: Curves.easeOutCubic),
),
);
_controller.forward();
_init();
}
@@ -22,6 +51,7 @@ class _SplashScreenState extends State<SplashScreen> {
await NotificationService.instance.init();
await context.read<ConfiguracionProvider>().cargarConfiguracion();
await NotificationService.instance.scheduleRecordatorios();
await Future.delayed(const Duration(milliseconds: 1200));
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const DashboardScreen()),
@@ -29,21 +59,75 @@ class _SplashScreenState extends State<SplashScreen> {
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.primary,
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.home_rounded, size: 80, color: Theme.of(context).colorScheme.onPrimary),
const SizedBox(height: 16),
Text('HogarControl',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onPrimary)),
const SizedBox(height: 24),
CircularProgressIndicator(color: Theme.of(context).colorScheme.onPrimary),
],
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.primary,
colorScheme.primaryContainer,
],
),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ScaleTransition(
scale: _scaleAnim,
child: Icon(
Icons.home_rounded,
size: 80,
color: colorScheme.onPrimary,
),
),
FadeTransition(
opacity: _fadeAnim,
child: SlideTransition(
position: _slideAnim,
child: Column(
children: [
const SizedBox(height: 16),
Text(
'Factura del Hogar',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: colorScheme.onPrimary,
),
),
const SizedBox(height: 8),
Text(
'Controla tus gastos del hogar',
style: TextStyle(
fontSize: 14,
color: colorScheme.onPrimary.withValues(alpha: 0.8),
),
),
],
),
),
),
const SizedBox(height: 48),
FadeTransition(
opacity: _fadeAnim,
child: CircularProgressIndicator(
color: colorScheme.onPrimary,
),
),
],
),
),
),
);

View File

@@ -0,0 +1,76 @@
import 'package:intl/intl.dart';
class CurrencyInfo {
final String code;
final String name;
final String symbol;
const CurrencyInfo({
required this.code,
required this.name,
required this.symbol,
});
}
class CurrencyUtils {
static const List<CurrencyInfo> allCurrencies = [
CurrencyInfo(code: 'USD', name: 'US Dollar', symbol: r'$'),
CurrencyInfo(code: 'EUR', name: 'Euro', symbol: ''),
CurrencyInfo(code: 'GBP', name: 'British Pound', symbol: '£'),
CurrencyInfo(code: 'JPY', name: 'Japanese Yen', symbol: '¥'),
CurrencyInfo(code: 'MXN', name: 'Mexican Peso', symbol: r'MX$'),
CurrencyInfo(code: 'COP', name: 'Colombian Peso', symbol: r'$'),
CurrencyInfo(code: 'ARS', name: 'Argentine Peso', symbol: r'$'),
CurrencyInfo(code: 'PEN', name: 'Peruvian Sol', symbol: 'S/'),
CurrencyInfo(code: 'CLP', name: 'Chilean Peso', symbol: r'$'),
CurrencyInfo(code: 'BRL', name: 'Brazilian Real', symbol: r'R$'),
CurrencyInfo(code: 'CAD', name: 'Canadian Dollar', symbol: r'CA$'),
CurrencyInfo(code: 'AUD', name: 'Australian Dollar', symbol: r'A$'),
CurrencyInfo(code: 'CHF', name: 'Swiss Franc', symbol: 'CHF'),
CurrencyInfo(code: 'CNY', name: 'Chinese Yuan', symbol: '¥'),
CurrencyInfo(code: 'INR', name: 'Indian Rupee', symbol: ''),
CurrencyInfo(code: 'KRW', name: 'South Korean Won', symbol: ''),
CurrencyInfo(code: 'SEK', name: 'Swedish Krona', symbol: 'kr'),
CurrencyInfo(code: 'NOK', name: 'Norwegian Krone', symbol: 'kr'),
CurrencyInfo(code: 'NZD', name: 'New Zealand Dollar', symbol: r'NZ$'),
CurrencyInfo(code: 'UYU', name: 'Uruguayan Peso', symbol: r'$U'),
CurrencyInfo(code: 'PYG', name: 'Paraguayan Guarani', symbol: ''),
CurrencyInfo(code: 'BOB', name: 'Bolivian Boliviano', symbol: 'Bs'),
CurrencyInfo(code: 'CRC', name: 'Costa Rican Colón', symbol: ''),
CurrencyInfo(code: 'DOP', name: 'Dominican Peso', symbol: r'RD$'),
CurrencyInfo(code: 'GTQ', name: 'Guatemalan Quetzal', symbol: 'Q'),
CurrencyInfo(code: 'HNL', name: 'Honduran Lempira', symbol: 'L'),
CurrencyInfo(code: 'NIO', name: 'Nicaraguan Córdoba', symbol: r'C$'),
CurrencyInfo(code: 'PAB', name: 'Panamanian Balboa', symbol: 'B/.'),
CurrencyInfo(code: 'VES', name: 'Venezuelan Bolívar', symbol: 'Bs.S'),
CurrencyInfo(code: 'SGD', name: 'Singapore Dollar', symbol: r'S$'),
CurrencyInfo(code: 'HKD', name: 'Hong Kong Dollar', symbol: r'HK$'),
CurrencyInfo(code: 'TRY', name: 'Turkish Lira', symbol: ''),
CurrencyInfo(code: 'TWD', name: 'Taiwan Dollar', symbol: r'NT$'),
CurrencyInfo(code: 'THB', name: 'Thai Baht', symbol: '฿'),
CurrencyInfo(code: 'ZAR', name: 'South African Rand', symbol: 'R'),
CurrencyInfo(code: 'ILS', name: 'Israeli Shekel', symbol: ''),
CurrencyInfo(code: 'DKK', name: 'Danish Krone', symbol: 'kr'),
CurrencyInfo(code: 'PLN', name: 'Polish Zloty', symbol: ''),
];
static const _zeroDecimal = {'JPY', 'KRW', 'CLP', 'PYG', 'COP'};
static String getSymbol(String code) {
final idx = allCurrencies.indexWhere((c) => c.code == code);
return idx >= 0 ? allCurrencies[idx].symbol : r'$';
}
static String getDisplayName(String code) {
final idx = allCurrencies.indexWhere((c) => c.code == code);
return idx >= 0 ? '${allCurrencies[idx].code} - ${allCurrencies[idx].name}' : code;
}
static String formatPrice(double amount, String code) {
final symbol = getSymbol(code);
final fmt = _zeroDecimal.contains(code)
? NumberFormat('#,##0')
: NumberFormat('#,##0.00');
return '$symbol${fmt.format(amount)}';
}
}

View File

@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
class SmoothPageTransitionBuilder extends PageTransitionsBuilder {
const SmoothPageTransitionBuilder();
@override
Widget buildTransitions<T>(
PageRoute<T> route,
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
const begin = Offset(0.0, 0.04);
const end = Offset.zero;
const curve = Curves.easeInOutCubic;
final tween = Tween(begin: begin, end: end).chain(
CurveTween(curve: curve),
);
final fadeTween = Tween<double>(begin: 0.0, end: 1.0);
return SlideTransition(
position: animation.drive(tween),
child: FadeTransition(
opacity: animation.drive(fadeTween),
child: child,
),
);
}
}

View File

@@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
class StatCard extends StatelessWidget {
class StatCard extends StatefulWidget {
final String title;
final String value;
final IconData icon;
final Color color;
final int delayMs;
const StatCard({
super.key,
@@ -12,26 +13,79 @@ class StatCard extends StatelessWidget {
required this.value,
required this.icon,
required this.color,
this.delayMs = 0,
});
@override
State<StatCard> createState() => _StatCardState();
}
class _StatCardState extends State<StatCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnim;
late Animation<Offset> _slideAnim;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_fadeAnim = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
_slideAnim = Tween<Offset>(
begin: const Offset(0, 0.15),
end: Offset.zero,
).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic),
);
if (widget.delayMs > 0) {
Future.delayed(Duration(milliseconds: widget.delayMs), () {
if (mounted) _controller.forward();
});
} else {
_controller.forward();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
final theme = Theme.of(context);
return FadeTransition(
opacity: _fadeAnim,
child: SlideTransition(
position: _slideAnim,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: color, size: 20),
const SizedBox(width: 8),
Text(title, style: Theme.of(context).textTheme.bodySmall),
Row(
children: [
Icon(widget.icon, color: widget.color, size: 20),
const SizedBox(width: 8),
Text(widget.title, style: theme.textTheme.bodySmall),
],
),
const SizedBox(height: 8),
Text(
widget.value,
style: theme.textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 8),
Text(value, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)),
],
),
),
),
);