diff --git a/AGENTS.md b/AGENTS.md index f0382f3..722f850 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,48 +1,55 @@ -# HogarControl +# Factura del Hogar -Flutter app for tracking home bills and recurring expenses. All data stored locally via SQLite — no internet required, no accounts. +Flutter app for tracking home bills and recurring expenses. All data stored locally via SQLite — no internet, no accounts. ## Build & install ```bash export JAVA_HOME=/root/.sdkman/candidates/java/21.0.11-amzn -flutter pub get -flutter build apk --debug -adb install build/app/outputs/flutter-apk/app-debug.apk +/opt/flutter/bin/flutter pub get +/opt/flutter/bin/flutter build apk --debug +/opt/android-sdk/platform-tools/adb install build/app/outputs/flutter-apk/app-debug.apk ``` ## Prerequisites - **JDK 21** via SDKMAN: `sdk use java 21.0.11-amzn` -- **Android SDK** at `/opt/android-sdk` (set via `flutter config --android-sdk /opt/android-sdk`) +- **Android SDK** at `/opt/android-sdk` - **Flutter 3.27.4** at `/opt/flutter/bin` -- NDK `27.0.12077973` (set in `android/app/build.gradle`) +- **adb** at `/opt/android-sdk/platform-tools/adb` (not in PATH) +- NDK `27.0.12077973` in `android/app/build.gradle` -## Key config +## Verify -- `android/app/build.gradle` — compileSdk 35, minSdk 26, targetSdk 35, coreLibraryDesugaringEnabled, AGP 8.7.3 -- `android/settings.gradle` — AGP + Kotlin plugin versions -- Permissions: `INTERNET`, `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, `SCHEDULE_EXACT_ALARM` +```bash +/opt/flutter/bin/dart analyze +export JAVA_HOME=/root/.sdkman/candidates/java/21.0.11-amzn && /opt/flutter/bin/flutter build apk --debug +``` + +No test suite configured beyond the default boilerplate (`test/widget_test.dart`). Lint shows 3 pre-existing issues (unused import in test file, deprecated `value` on Color, async BuildContext) — safe to ignore. ## Stack - **State**: Provider (`ChangeNotifierProvider` for `Servicio`, `Factura`, `Configuracion`) -- **DB**: sqflite (single DB `hogarcontrol.db`, tables: `servicios`, `facturas`, `configuracion`) +- **DB**: sqflite (single `hogarcontrol.db`, tables: `servicios`, `facturas`, `configuracion`) - **Notifications**: `flutter_local_notifications` with `zonedSchedule` + timezone - **Charts**: fl_chart (bar chart) + table_calendar -- **UI**: Material 3, no router (manual Navigator pushes) +- **UI**: Material 3, `ThemeMode.system` (light/dark via system), custom `SmoothPageTransitionBuilder` (fade+slide on all pushes) +- **Navigation**: manual `Navigator.push` with `MaterialPageRoute` — no router ## Project layout | Path | Purpose | |---|---| -| `lib/main.dart` | App entry, MultiProvider setup, theme | +| `lib/main.dart` | Entry, MultiProvider, `_buildLightTheme()` / `_buildDarkTheme()` | | `lib/models/` | Data classes (`Servicio`, `Factura`, `Configuracion`) | | `lib/database/database_helper.dart` | Singleton DB with CRUD helpers | | `lib/providers/` | ChangeNotifier state classes | | `lib/screens/` | 9 screens (Splash → Dashboard → ...) | | `lib/services/notification_service.dart` | Local notification scheduling | -| `lib/widgets/` | Reusable UI components | +| `lib/widgets/` | `StatCard` (animated fade+slide on mount) | +| `lib/utils/currency_utils.dart` | 37 currencies with `formatPrice()`, `getSymbol()`, `getDisplayName()` | +| `lib/utils/transitions.dart` | `SmoothPageTransitionBuilder` for global page transitions | ## DB schema @@ -50,8 +57,37 @@ adb install build/app/outputs/flutter-apk/app-debug.apk - `facturas` — id, servicio_id (FK), monto, fecha_emision, fecha_vencimiento, estado, nota, fecha_pago - `configuracion` — id, dias_recordatorio (comma-separated), tema, moneda +## Key Android config + +- `android/app/build.gradle` — compileSdk 35, minSdk 26, targetSdk 35, coreLibraryDesugaringEnabled, AGP 8.7.3 +- `android/settings.gradle` — AGP 8.7.3, Kotlin 2.0.21 +- Permissions (main manifest): `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, `SCHEDULE_EXACT_ALARM` +- `INTERNET` only in debug/profile manifests (Flutter dev requirement — not in release) + +## Currency helper + +Always use `CurrencyUtils` instead of hardcoding symbols: +- `CurrencyUtils.formatPrice(amount, code)` — returns e.g. `$1,234.56` or `€1.234,56` +- `CurrencyUtils.getSymbol(code)` — symbol only +- `CurrencyUtils.getDisplayName(code)` — e.g. `"USD - US Dollar"` +- Zero-decimal currencies (JPY, KRW, CLP, PYG, COP) are handled automatically +- List: `CurrencyUtils.allCurrencies` (37 currencies) + +## Styling conventions + +- All cards use `BorderRadius.circular(16)` via `CardTheme` +- Input fields use `filled: true` with `BorderRadius.circular(12)` +- Page transitions: fade + slight vertical slide (defined in `transitions.dart`) +- List items animate in with `TweenAnimationBuilder` using staggered delays (`300 + i * 60` ms pattern) +- `StatCard` is a `StatefulWidget` with `SingleTickerProviderStateMixin` — delay via `delayMs` prop + ## Warnings -- `dart analyze` may flag unused imports/variables in screens — safe to ignore for widget-tree watchers +- `dart analyze` flags unused imports/variables in screens — safe to ignore for widget-tree watchers - Running as root causes flutter warnings but doesn't break builds - Flutter 3.27.4 has known warnings about x86 target deprecation + +## Remote + +- Origin: `https://git.devlab.lat/android/home-control.git` +- Credentials: `agent` + token `b453c4526d9b4857f1263c9a80514488` diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..ba32075 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,37 @@ +# Política de Privacidad + +**Última actualización:** 14 de junio de 2026 + +## Responsable + +**Nombre de la app:** Factura del Hogar +**Desarrollador:** Agent +**Contacto:** agent@devlab.lat + +## Recopilación de datos + +Factura del Hogar **no recopila, almacena ni transmite ningún dato personal** del usuario. Toda la información ingresada en la aplicación (servicios, facturas, configuración) se almacena exclusivamente en el dispositivo del usuario mediante una base de datos SQLite local. + +## Permisos solicitados + +- **POST_NOTIFICATIONS:** para mostrar recordatorios de vencimiento de facturas. +- **RECEIVE_BOOT_COMPLETED:** para reprogramar recordatorios después de reiniciar el dispositivo. +- **SCHEDULE_EXACT_ALARM:** para programar notificaciones en horarios exactos. + +Ninguno de estos permisos se utiliza para acceder, recopilar o transmitir datos personales. + +## Conexión a internet + +La aplicación **no requiere conexión a internet** para funcionar. No realiza comunicaciones de red, no envía datos a servidores externos ni integra servicios de terceros que recopilen información. + +## Servicios de terceros + +Esta aplicación no integra SDKs de terceros (analíticas, publicidad, redes sociales, etc.). + +## Cambios en esta política + +Cualquier cambio en esta política de privacidad se reflejará en esta misma página. + +--- + +*Factura del Hogar — Controla tus gastos del hogar de forma privada y segura.* diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 48674ae..5f49743 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,7 +3,7 @@ { context.watch(); context.watch(); final configProv = context.watch(); - 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 { 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( + 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(), + }, ), ), ], diff --git a/lib/screens/configuracion_screen.dart b/lib/screens/configuracion_screen.dart index fbb86da..8edae29 100644 --- a/lib/screens/configuracion_screen.dart +++ b/lib/screens/configuracion_screen.dart @@ -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 { {'label': 'Día de vencimiento', 'value': 0}, ]; - final List _monedas = ['USD', 'EUR', 'MXN', 'COP', 'ARS', 'PEN', 'CLP', 'BRL']; + final List _monedas = CurrencyUtils.allCurrencies; @override void initState() { @@ -59,7 +60,7 @@ class _ConfiguracionScreenState extends State { DropdownButtonFormField( 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), diff --git a/lib/screens/crear_factura_screen.dart b/lib/screens/crear_factura_screen.dart index 3a3e840..4430ecf 100644 --- a/lib/screens/crear_factura_screen.dart +++ b/lib/screens/crear_factura_screen.dart @@ -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 { Widget build(BuildContext context) { final servicioProv = context.watch(); final facturaProv = context.read(); + final configProv = context.watch(); + 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 { 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'; diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart index 5066f87..9788a2e 100644 --- a/lib/screens/dashboard_screen.dart +++ b/lib/screens/dashboard_screen.dart @@ -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 { final servicioProv = context.watch(); final configProv = context.watch(); 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 { 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 { value: '$vencidas', icon: Icons.warning_rounded, color: Colors.red, + delayMs: 100, )), ], ), @@ -76,36 +77,74 @@ class _DashboardScreenState extends State { 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( + 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', + ), + ], + ), ), ), ); diff --git a/lib/screens/estadisticas_screen.dart b/lib/screens/estadisticas_screen.dart index 23c37e7..3465a5e 100644 --- a/lib/screens/estadisticas_screen.dart +++ b/lib/screens/estadisticas_screen.dart @@ -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 { final facturaProv = context.watch(); final configProv = context.watch(); 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 { 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 { ); }), 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 { 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( + 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(), diff --git a/lib/screens/facturas_screen.dart b/lib/screens/facturas_screen.dart index aadace0..8e2f25e 100644 --- a/lib/screens/facturas_screen.dart +++ b/lib/screens/facturas_screen.dart @@ -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 { final servicioProv = context.watch(); final configProv = context.watch(); 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 { 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( + 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!), - ), - ], + ], + ), ), ), ); diff --git a/lib/screens/servicios_screen.dart b/lib/screens/servicios_screen.dart index 3ed5ff8..0dde152 100644 --- a/lib/screens/servicios_screen.dart +++ b/lib/screens/servicios_screen.dart @@ -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( + 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( + 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( - 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()), ), ); }, diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index dc99f94..e7dbb6f 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -11,10 +11,39 @@ class SplashScreen extends StatefulWidget { State createState() => _SplashScreenState(); } -class _SplashScreenState extends State { +class _SplashScreenState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnim; + late Animation _fadeAnim; + late Animation _slideAnim; + @override void initState() { super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + ); + _scaleAnim = Tween(begin: 0.5, end: 1.0).animate( + CurvedAnimation(parent: _controller, curve: Curves.elasticOut), + ); + _fadeAnim = Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.3, 0.7, curve: Curves.easeIn), + ), + ); + _slideAnim = Tween( + 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 { await NotificationService.instance.init(); await context.read().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 { } } + @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, + ), + ), + ], + ), ), ), ); diff --git a/lib/utils/currency_utils.dart b/lib/utils/currency_utils.dart new file mode 100644 index 0000000..224f4cb --- /dev/null +++ b/lib/utils/currency_utils.dart @@ -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 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: 'zł'), + ]; + + 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)}'; + } +} diff --git a/lib/utils/transitions.dart b/lib/utils/transitions.dart new file mode 100644 index 0000000..da1edc6 --- /dev/null +++ b/lib/utils/transitions.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class SmoothPageTransitionBuilder extends PageTransitionsBuilder { + const SmoothPageTransitionBuilder(); + + @override + Widget buildTransitions( + PageRoute route, + BuildContext context, + Animation animation, + Animation 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(begin: 0.0, end: 1.0); + return SlideTransition( + position: animation.drive(tween), + child: FadeTransition( + opacity: animation.drive(fadeTween), + child: child, + ), + ); + } +} diff --git a/lib/widgets/stat_card.dart b/lib/widgets/stat_card.dart index 2939ff4..0aef58c 100644 --- a/lib/widgets/stat_card.dart +++ b/lib/widgets/stat_card.dart @@ -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 createState() => _StatCardState(); +} + +class _StatCardState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _fadeAnim; + late Animation _slideAnim; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 400), + vsync: this, + ); + _fadeAnim = Tween(begin: 0, end: 1).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeOut), + ); + _slideAnim = Tween( + 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)), - ], + ), ), ), );