From bbd20f66daa4eb0aa91b99dc9709c5b060f36240 Mon Sep 17 00:00:00 2001 From: Super User Date: Mon, 15 Jun 2026 21:45:59 -0400 Subject: [PATCH] =?UTF-8?q?Facturas=20recurrentes,=20edici=C3=B3n,=20paddi?= =?UTF-8?q?ng,=20y=20pol=C3=ADtica=20de=20privacidad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Servicio: nuevo campo esRecurrente + migración DB v2 - Al marcar pagada una factura recurrente se genera la siguiente - Editar facturas existentes desde CrearFacturaScreen - Nuevo método actualizarFactura en FacturaProvider - padding inferior en listas de facturas y servicios (último item visible) - Nota de factura visible en subtítulo - Filtro Vencidas corregido - Monto pendiente total visible en dashboard - Botón undo para revertir factura pagada - Configuración de release signing (keystore) - Pantalla de política de privacidad en la app + PRIVACY_POLICY.md --- PRIVACY_POLICY.md | 35 +++++++++++++++ android/app/build.gradle | 19 ++++++-- lib/database/database_helper.dart | 16 ++++++- lib/models/servicio.dart | 23 ++++++++++ lib/providers/factura_provider.dart | 40 ++++++++++++++++- lib/screens/configuracion_screen.dart | 11 ++++- lib/screens/crear_factura_screen.dart | 25 ++++++++--- lib/screens/crear_servicio_screen.dart | 10 +++++ lib/screens/dashboard_screen.dart | 10 ++++- lib/screens/facturas_screen.dart | 23 ++++++++-- lib/screens/privacy_policy_screen.dart | 62 ++++++++++++++++++++++++++ lib/screens/servicios_screen.dart | 2 +- 12 files changed, 260 insertions(+), 16 deletions(-) create mode 100644 PRIVACY_POLICY.md create mode 100644 lib/screens/privacy_policy_screen.dart diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md new file mode 100644 index 0000000..44092bb --- /dev/null +++ b/PRIVACY_POLICY.md @@ -0,0 +1,35 @@ +# Privacy Policy — Factura del Hogar + +**Last updated:** June 2026 + +## Data Collection + +Factura del Hogar **does not collect, transmit, or share any personal data.** All information you enter (bills, services, amounts, and settings) is stored exclusively on your device using a local SQLite database. + +## Internet Access + +The app **does not require internet access.** The `INTERNET` permission is declared only in debug and profile builds (required by the Flutter framework for development features like hot reload). The release version distributed through Google Play has no internet permission and no network capabilities. + +## Permissions + +- **POST_NOTIFICATIONS**: Used to show local reminders about bill due dates. +- **RECEIVE_BOOT_COMPLETED**: Used to reschedule notifications after a device restart. +- **SCHEDULE_EXACT_ALARM**: Used to deliver bill reminders at the exact scheduled time. + +No permission is used to access the internet, accounts, or external storage. + +## Third-Party Services + +This app uses no third-party analytics, advertising, or tracking services. No data is sent to any server. + +## Data Deletion + +Since all data is stored locally, uninstalling the app removes all associated data. You can also delete individual invoices or services through the app's interface. + +## Contact + +If you have questions about this privacy policy, please open an issue at the project repository. + +--- + +© 2026 Factura del Hogar diff --git a/android/app/build.gradle b/android/app/build.gradle index 1b54729..bca459c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -5,6 +5,12 @@ plugins { id "dev.flutter.flutter-gradle-plugin" } +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + android { namespace = "com.hogarcontrol.hogarcontrol" compileSdk = 35 @@ -28,11 +34,18 @@ android { versionName = flutter.versionName } + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile keystoreProperties['storeFile'] ? rootProject.file(keystoreProperties['storeFile']) : null + storePassword keystoreProperties['storePassword'] + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.debug + signingConfig = signingConfigs.release } } } diff --git a/lib/database/database_helper.dart b/lib/database/database_helper.dart index 9549681..958645b 100644 --- a/lib/database/database_helper.dart +++ b/lib/database/database_helper.dart @@ -19,13 +19,20 @@ class DatabaseHelper { Future _initDB(String filePath) async { final dbPath = await getDatabasesPath(); final path = join(dbPath, filePath); - return await openDatabase(path, version: 1, onCreate: _createDB, onConfigure: _onConfigure); + return await openDatabase(path, version: 2, onCreate: _createDB, onUpgrade: _upgradeDB, onConfigure: _onConfigure); } Future _onConfigure(Database db) async { await db.execute('PRAGMA foreign_keys = ON'); } + Future _upgradeDB(Database db, int oldVersion, int newVersion) async { + if (oldVersion < 2) { + await db.execute( + "ALTER TABLE servicios ADD COLUMN es_recurrente INTEGER NOT NULL DEFAULT 0"); + } + } + Future _createDB(Database db, int version) async { await db.execute(''' CREATE TABLE servicios ( @@ -81,6 +88,13 @@ class DatabaseHelper { return await db.delete('servicios', where: 'id = ?', whereArgs: [id]); } + Future getServicio(int id) async { + final db = await database; + final maps = await db.query('servicios', where: 'id = ?', whereArgs: [id], limit: 1); + if (maps.isEmpty) return null; + return Servicio.fromMap(maps.first); + } + Future insertFactura(Factura factura) async { final db = await database; return await db.insert('facturas', factura.toMap()..remove('id')); diff --git a/lib/models/servicio.dart b/lib/models/servicio.dart index 45b2823..1c87a92 100644 --- a/lib/models/servicio.dart +++ b/lib/models/servicio.dart @@ -5,6 +5,7 @@ class Servicio { final int color; final int diaVencimiento; final String fechaCreacion; + final bool esRecurrente; Servicio({ this.id, @@ -13,8 +14,28 @@ class Servicio { required this.color, required this.diaVencimiento, required this.fechaCreacion, + this.esRecurrente = false, }); + Servicio copyWith({ + int? id, + String? nombre, + String? icono, + int? color, + int? diaVencimiento, + String? fechaCreacion, + bool? esRecurrente, + }) => + Servicio( + id: id ?? this.id, + nombre: nombre ?? this.nombre, + icono: icono ?? this.icono, + color: color ?? this.color, + diaVencimiento: diaVencimiento ?? this.diaVencimiento, + fechaCreacion: fechaCreacion ?? this.fechaCreacion, + esRecurrente: esRecurrente ?? this.esRecurrente, + ); + Map toMap() => { 'id': id, 'nombre': nombre, @@ -22,6 +43,7 @@ class Servicio { 'color': color, 'dia_vencimiento': diaVencimiento, 'fecha_creacion': fechaCreacion, + 'es_recurrente': esRecurrente ? 1 : 0, }; factory Servicio.fromMap(Map map) => Servicio( @@ -31,5 +53,6 @@ class Servicio { color: map['color'] as int, diaVencimiento: map['dia_vencimiento'] as int, fechaCreacion: map['fecha_creacion'] as String, + esRecurrente: (map['es_recurrente'] as int? ?? 0) == 1, ); } diff --git a/lib/providers/factura_provider.dart b/lib/providers/factura_provider.dart index c68c934..06442e1 100644 --- a/lib/providers/factura_provider.dart +++ b/lib/providers/factura_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; import '../database/database_helper.dart'; import '../models/factura.dart'; @@ -15,7 +16,11 @@ class FacturaProvider extends ChangeNotifier { Future cargarFacturas({String? estado, int? servicioId}) async { _cargando = true; notifyListeners(); - _facturas = await _db.getFacturas(estado: estado, servicioId: servicioId); + if (estado == 'vencidas') { + _facturas = await _db.getFacturasVencidas(); + } else { + _facturas = await _db.getFacturas(estado: estado, servicioId: servicioId); + } _facturasVencidas = await _db.getFacturasVencidas(); _cargando = false; notifyListeners(); @@ -31,6 +36,39 @@ class FacturaProvider extends ChangeNotifier { final hoy = DateTime.now().toIso8601String().split('T')[0]; final actualizada = factura.copyWith(estado: 'Pagada', fechaPago: hoy); await _db.updateFactura(actualizada); + + final servicio = await _db.getServicio(factura.servicioId); + if (servicio != null && servicio.esRecurrente) { + final nextEmision = _sumarMes(factura.fechaEmision); + final nextVencimiento = _sumarMes(factura.fechaVencimiento); + final nueva = Factura( + servicioId: factura.servicioId, + monto: factura.monto, + fechaEmision: nextEmision, + fechaVencimiento: nextVencimiento, + nota: factura.nota, + ); + await _db.insertFactura(nueva); + } + + await cargarFacturas(); + } + + String _sumarMes(String dateStr) { + final date = DateTime.parse(dateStr); + final next = DateTime(date.year, date.month + 1, date.day); + return DateFormat('yyyy-MM-dd').format(next); + } + + Future marcarPendiente(int id) async { + final factura = _facturas.firstWhere((f) => f.id == id); + final actualizada = factura.copyWith(estado: 'Pendiente', fechaPago: null); + await _db.updateFactura(actualizada); + await cargarFacturas(); + } + + Future actualizarFactura(Factura factura) async { + await _db.updateFactura(factura); await cargarFacturas(); } diff --git a/lib/screens/configuracion_screen.dart b/lib/screens/configuracion_screen.dart index 8edae29..e4eacb1 100644 --- a/lib/screens/configuracion_screen.dart +++ b/lib/screens/configuracion_screen.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../models/configuracion.dart'; import '../providers/configuracion_provider.dart'; import '../utils/currency_utils.dart'; +import 'privacy_policy_screen.dart'; class ConfiguracionScreen extends StatefulWidget { const ConfiguracionScreen({super.key}); @@ -64,7 +65,15 @@ class _ConfiguracionScreenState extends State { onChanged: (v) => setState(() => _moneda = v!), ), const SizedBox(height: 32), - FilledButton( + const SizedBox(height: 16), + ListTile( + leading: const Icon(Icons.privacy_tip_outlined), + title: const Text('Política de privacidad'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const PrivacyPolicyScreen())), + ), + const SizedBox(height: 16), + FilledButton( onPressed: () async { final diasStr = _opcionesDias .asMap() diff --git a/lib/screens/crear_factura_screen.dart b/lib/screens/crear_factura_screen.dart index 4430ecf..b0d29f6 100644 --- a/lib/screens/crear_factura_screen.dart +++ b/lib/screens/crear_factura_screen.dart @@ -8,7 +8,8 @@ import '../providers/configuracion_provider.dart'; import '../utils/currency_utils.dart'; class CrearFacturaScreen extends StatefulWidget { - const CrearFacturaScreen({super.key}); + final Factura? factura; + const CrearFacturaScreen({super.key, this.factura}); @override State createState() => _CrearFacturaScreenState(); @@ -28,6 +29,15 @@ class _CrearFacturaScreenState extends State { super.initState(); _montoCtrl = TextEditingController(); _notaCtrl = TextEditingController(); + if (widget.factura != null) { + final f = widget.factura!; + _montoCtrl.text = f.monto.toString(); + _notaCtrl.text = f.nota ?? ''; + _servicioId = f.servicioId; + _fechaEmision = DateTime.parse(f.fechaEmision); + _fechaVencimiento = DateTime.parse(f.fechaVencimiento); + _estado = f.estado; + } context.read().cargarServicios(); } @@ -47,7 +57,7 @@ class _CrearFacturaScreenState extends State { final symbol = CurrencyUtils.getSymbol(moneda); return Scaffold( - appBar: AppBar(title: const Text('Nueva factura')), + appBar: AppBar(title: Text(widget.factura != null ? 'Editar factura' : 'Nueva factura')), body: Form( key: _formKey, child: ListView( @@ -110,18 +120,23 @@ class _CrearFacturaScreenState extends State { onPressed: () async { if (!_formKey.currentState!.validate()) return; final factura = Factura( + id: widget.factura?.id, servicioId: _servicioId!, monto: double.parse(_montoCtrl.text), fechaEmision: DateFormat('yyyy-MM-dd').format(_fechaEmision), fechaVencimiento: DateFormat('yyyy-MM-dd').format(_fechaVencimiento), estado: _estado, nota: _notaCtrl.text.isEmpty ? null : _notaCtrl.text, - fechaPago: _estado == 'Pagada' ? DateFormat('yyyy-MM-dd').format(DateTime.now()) : null, + fechaPago: widget.factura?.fechaPago ?? (_estado == 'Pagada' ? DateFormat('yyyy-MM-dd').format(DateTime.now()) : null), ); - await facturaProv.agregarFactura(factura); + if (widget.factura != null) { + await facturaProv.actualizarFactura(factura); + } else { + await facturaProv.agregarFactura(factura); + } if (context.mounted) Navigator.pop(context); }, - child: const Text('Guardar factura'), + child: Text(widget.factura != null ? 'Guardar cambios' : 'Guardar factura'), ), ], ), diff --git a/lib/screens/crear_servicio_screen.dart b/lib/screens/crear_servicio_screen.dart index 460bdcc..5fdc51a 100644 --- a/lib/screens/crear_servicio_screen.dart +++ b/lib/screens/crear_servicio_screen.dart @@ -18,6 +18,7 @@ class _CrearServicioScreenState extends State { String _iconoSeleccionado = 'recibo'; Color _colorSeleccionado = Colors.purple; int _diaVencimiento = 15; + bool _esRecurrente = false; final List> _iconos = [ {'key': 'electricidad', 'icon': Icons.bolt, 'label': 'Electricidad'}, @@ -46,6 +47,7 @@ class _CrearServicioScreenState extends State { _iconoSeleccionado = widget.servicio!.icono; _colorSeleccionado = Color(widget.servicio!.color); _diaVencimiento = widget.servicio!.diaVencimiento; + _esRecurrente = widget.servicio!.esRecurrente; } } @@ -111,6 +113,13 @@ class _CrearServicioScreenState extends State { ); }).toList(), ), + const SizedBox(height: 8), + SwitchListTile( + title: const Text('Factura recurrente'), + subtitle: const Text('Generar automáticamente la siguiente factura al pagar'), + value: _esRecurrente, + onChanged: (v) => setState(() => _esRecurrente = v), + ), const SizedBox(height: 20), Text('Día de vencimiento', style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 8), @@ -131,6 +140,7 @@ class _CrearServicioScreenState extends State { color: _colorSeleccionado.value, diaVencimiento: _diaVencimiento, fechaCreacion: widget.servicio?.fechaCreacion ?? DateFormat('yyyy-MM-dd').format(DateTime.now()), + esRecurrente: _esRecurrente, ); if (widget.servicio != null) { await provider.actualizarServicio(servicio); diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart index 9788a2e..3189658 100644 --- a/lib/screens/dashboard_screen.dart +++ b/lib/screens/dashboard_screen.dart @@ -39,6 +39,9 @@ class _DashboardScreenState extends State { final moneda = configProv.config?.moneda ?? 'USD'; final pendientes = facturaProv.facturas.where((f) => f.estado == 'Pendiente').length; + final pendienteTotal = facturaProv.facturas + .where((f) => f.estado == 'Pendiente') + .fold(0.0, (sum, f) => sum + f.monto); final vencidas = facturaProv.facturasVencidas.length; return Scaffold( @@ -48,7 +51,7 @@ class _DashboardScreenState extends State { body: RefreshIndicator( onRefresh: () async => _cargarDatos(), child: ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 80), children: [ Row( children: [ @@ -95,6 +98,11 @@ class _DashboardScreenState extends State { 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 Spacer(), + Text(CurrencyUtils.formatPrice(pendienteTotal, moneda), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary)), ], ), const SizedBox(height: 12), diff --git a/lib/screens/facturas_screen.dart b/lib/screens/facturas_screen.dart index 8e2f25e..3703995 100644 --- a/lib/screens/facturas_screen.dart +++ b/lib/screens/facturas_screen.dart @@ -4,6 +4,7 @@ import '../providers/factura_provider.dart'; import '../providers/servicio_provider.dart'; import '../providers/configuracion_provider.dart'; import '../utils/currency_utils.dart'; +import 'crear_factura_screen.dart'; class FacturasScreen extends StatefulWidget { const FacturasScreen({super.key}); @@ -45,7 +46,7 @@ class _FacturasScreenState extends State { const SizedBox(width: 8), FilterChip(label: const Text('Pagadas'), selected: _filtroEstado == 'Pagada', onSelected: (_) => setState(() { _filtroEstado = 'Pagada'; _filtroServicio = null; facturaProv.cargarFacturas(estado: 'Pagada'); })), const SizedBox(width: 8), - FilterChip(label: const Text('Vencidas'), selected: _filtroEstado == 'vencidas', onSelected: (_) => setState(() { _filtroEstado = 'vencidas'; _filtroServicio = null; facturaProv.facturasVencidas; })), + FilterChip(label: const Text('Vencidas'), selected: _filtroEstado == 'vencidas', onSelected: (_) => setState(() { _filtroEstado = 'vencidas'; _filtroServicio = null; facturaProv.cargarFacturas(estado: 'vencidas'); })), ], ), ), @@ -54,7 +55,7 @@ class _FacturasScreenState extends State { child: facturaProv.cargando ? const Center(child: CircularProgressIndicator()) : ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), itemCount: facturaProv.facturas.length + (facturaProv.facturas.isEmpty ? 1 : 0), itemBuilder: (_, i) { if (facturaProv.facturas.isEmpty) { @@ -97,7 +98,13 @@ class _FacturasScreenState extends State { ), 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 ?? ''}' : ''}'), + subtitle: Text.rich(TextSpan(children: [ + TextSpan(text: 'Vence: ${f.fechaVencimiento}'), + if (f.estado == 'Pagada' && f.fechaPago != null) + TextSpan(text: ' Pagada: ${f.fechaPago}'), + if (f.nota != null && f.nota!.isNotEmpty) + TextSpan(text: '\n${f.nota}', style: TextStyle(color: Colors.grey[600], fontSize: 12)), + ])), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -105,7 +112,17 @@ class _FacturasScreenState extends State { IconButton( icon: Icon(Icons.check_circle, color: vencida ? Colors.red : Colors.green), onPressed: () => facturaProv.marcarPagada(f.id!), + ) + else + IconButton( + icon: const Icon(Icons.undo, color: Colors.orange), + onPressed: () => facturaProv.marcarPendiente(f.id!), + tooltip: 'Marcar pendiente', ), + IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => CrearFacturaScreen(factura: f))).then((_) => facturaProv.cargarFacturas()), + ), IconButton( icon: const Icon(Icons.delete_outline, color: Colors.red), onPressed: () => facturaProv.eliminarFactura(f.id!), diff --git a/lib/screens/privacy_policy_screen.dart b/lib/screens/privacy_policy_screen.dart new file mode 100644 index 0000000..137085b --- /dev/null +++ b/lib/screens/privacy_policy_screen.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +class PrivacyPolicyScreen extends StatelessWidget { + const PrivacyPolicyScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Política de Privacidad')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text('Factura del Hogar', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 24), + _section(context, 'Recolección de datos', + 'Factura del Hogar no recolecta, transmite ni comparte ningún dato personal. ' + 'Toda la información que ingresas (facturas, servicios, montos y configuración) ' + 'se almacena exclusivamente en tu dispositivo usando una base de datos SQLite local.'), + const SizedBox(height: 16), + _section(context, 'Acceso a Internet', + 'La aplicación no requiere acceso a Internet. El permiso INTERNET solo se ' + 'declara en builds de depuración (requerido por Flutter para recarga en caliente). ' + 'La versión de distribución en Google Play no tiene permiso de Internet ni ' + 'capacidades de red.'), + const SizedBox(height: 16), + _section(context, 'Permisos', + '• POST_NOTIFICATIONS: Para mostrar recordatorios locales de vencimiento de facturas.\n' + '• RECEIVE_BOOT_COMPLETED: Para reprogramar notificaciones después de reiniciar el dispositivo.\n' + '• SCHEDULE_EXACT_ALARM: Para entregar recordatorios a la hora exacta programada.\n\n' + 'Ningún permiso se usa para acceder a Internet, cuentas o almacenamiento externo.'), + const SizedBox(height: 16), + _section(context, 'Servicios de terceros', + 'Esta aplicación no usa servicios de análisis, publicidad ni rastreo de terceros. ' + 'No se envía ningún dato a ningún servidor.'), + const SizedBox(height: 16), + _section(context, 'Eliminación de datos', + 'Como todos los datos se almacenan localmente, desinstalar la aplicación elimina ' + 'todos los datos asociados. También puedes eliminar facturas o servicios ' + 'individualmente desde la interfaz de la aplicación.'), + const SizedBox(height: 16), + _section(context, 'Contacto', + 'Si tienes preguntas sobre esta política de privacidad, abre un issue en el ' + 'repositorio del proyecto.'), + const SizedBox(height: 16), + Text('Última actualización: Junio 2026', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)), + const SizedBox(height: 32), + ], + ), + ); + } + + Widget _section(BuildContext context, String title, String body) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Text(body, style: Theme.of(context).textTheme.bodyMedium), + ], + ); + } +} diff --git a/lib/screens/servicios_screen.dart b/lib/screens/servicios_screen.dart index 0dde152..80c0e74 100644 --- a/lib/screens/servicios_screen.dart +++ b/lib/screens/servicios_screen.dart @@ -28,7 +28,7 @@ class ServiciosScreen extends StatelessWidget { ), ) : ListView.builder( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 80), itemCount: provider.servicios.length, itemBuilder: (_, i) { final s = provider.servicios[i]; -- 2.43.0