commit 9ac9e9e953071b5b5d4f9182f838fea146dfab73 Author: Super User Date: Sun Jun 14 15:03:29 2026 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79c113f --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..9c3a509 --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d8a9f9a52e5af486f80d932e838ee93861ffd863" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + - platform: android + create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f0382f3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# HogarControl + +Flutter app for tracking home bills and recurring expenses. All data stored locally via SQLite — no internet required, 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 +``` + +## 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`) +- **Flutter 3.27.4** at `/opt/flutter/bin` +- NDK `27.0.12077973` (set in `android/app/build.gradle`) + +## Key config + +- `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` + +## Stack + +- **State**: Provider (`ChangeNotifierProvider` for `Servicio`, `Factura`, `Configuracion`) +- **DB**: sqflite (single DB `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) + +## Project layout + +| Path | Purpose | +|---|---| +| `lib/main.dart` | App entry, MultiProvider setup, theme | +| `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 | + +## DB schema + +- `servicios` — id, nombre, icono, color, dia_vencimiento, fecha_creacion +- `facturas` — id, servicio_id (FK), monto, fecha_emision, fecha_vencimiento, estado, nota, fecha_pago +- `configuracion` — id, dias_recordatorio (comma-separated), tema, moneda + +## Warnings + +- `dart analyze` may flag 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..86c75fd --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# hogarcontrol + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..55afd91 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..1b54729 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,46 @@ +plugins { + id "com.android.application" + id "kotlin-android" + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id "dev.flutter.flutter-gradle-plugin" +} + +android { + namespace = "com.hogarcontrol.hogarcontrol" + compileSdk = 35 + ndkVersion = "27.0.12077973" + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + coreLibraryDesugaringEnabled true + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17 + } + + defaultConfig { + applicationId = "com.hogarcontrol.hogarcontrol" + minSdk = 26 + targetSdk = 35 + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + 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 + } + } +} + +flutter { + source = "../.." +} + +dependencies { + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.4" +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..48674ae --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/hogarcontrol/hogarcontrol/MainActivity.kt b/android/app/src/main/kotlin/com/hogarcontrol/hogarcontrol/MainActivity.kt new file mode 100644 index 0000000..deadcce --- /dev/null +++ b/android/app/src/main/kotlin/com/hogarcontrol/hogarcontrol/MainActivity.kt @@ -0,0 +1,5 @@ +package com.hogarcontrol.hogarcontrol + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..d2ffbff --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..2597170 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..6cb8454 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..a9f0eb1 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.7.3" apply false + id "org.jetbrains.kotlin.android" version "2.0.21" apply false +} + +include ":app" diff --git a/lib/database/database_helper.dart b/lib/database/database_helper.dart new file mode 100644 index 0000000..9549681 --- /dev/null +++ b/lib/database/database_helper.dart @@ -0,0 +1,193 @@ +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart'; +import '../models/servicio.dart'; +import '../models/factura.dart'; +import '../models/configuracion.dart'; + +class DatabaseHelper { + static final DatabaseHelper instance = DatabaseHelper._init(); + static Database? _database; + + DatabaseHelper._init(); + + Future get database async { + if (_database != null) return _database!; + _database = await _initDB('hogarcontrol.db'); + return _database!; + } + + Future _initDB(String filePath) async { + final dbPath = await getDatabasesPath(); + final path = join(dbPath, filePath); + return await openDatabase(path, version: 1, onCreate: _createDB, onConfigure: _onConfigure); + } + + Future _onConfigure(Database db) async { + await db.execute('PRAGMA foreign_keys = ON'); + } + + Future _createDB(Database db, int version) async { + await db.execute(''' + CREATE TABLE servicios ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nombre TEXT NOT NULL, + icono TEXT NOT NULL, + color INTEGER NOT NULL, + dia_vencimiento INTEGER NOT NULL, + fecha_creacion TEXT NOT NULL + ) + '''); + await db.execute(''' + CREATE TABLE facturas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + servicio_id INTEGER NOT NULL, + monto REAL NOT NULL, + fecha_emision TEXT NOT NULL, + fecha_vencimiento TEXT NOT NULL, + estado TEXT NOT NULL DEFAULT 'Pendiente', + nota TEXT, + fecha_pago TEXT, + FOREIGN KEY (servicio_id) REFERENCES servicios(id) ON DELETE CASCADE + ) + '''); + await db.execute(''' + CREATE TABLE configuracion ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dias_recordatorio TEXT NOT NULL DEFAULT '7,3,1,0', + tema TEXT NOT NULL DEFAULT 'claro', + moneda TEXT NOT NULL DEFAULT 'USD' + ) + '''); + } + + Future insertServicio(Servicio servicio) async { + final db = await database; + return await db.insert('servicios', servicio.toMap()..remove('id')); + } + + Future> getServicios() async { + final db = await database; + final maps = await db.query('servicios', orderBy: 'nombre ASC'); + return maps.map((map) => Servicio.fromMap(map)).toList(); + } + + Future updateServicio(Servicio servicio) async { + final db = await database; + return await db.update('servicios', servicio.toMap(), where: 'id = ?', whereArgs: [servicio.id]); + } + + Future deleteServicio(int id) async { + final db = await database; + return await db.delete('servicios', where: 'id = ?', whereArgs: [id]); + } + + Future insertFactura(Factura factura) async { + final db = await database; + return await db.insert('facturas', factura.toMap()..remove('id')); + } + + Future> getFacturas({String? estado, int? servicioId}) async { + final db = await database; + String? where; + List? whereArgs; + if (estado != null && servicioId != null) { + where = 'estado = ? AND servicio_id = ?'; + whereArgs = [estado, servicioId]; + } else if (estado != null) { + where = 'estado = ?'; + whereArgs = [estado]; + } else if (servicioId != null) { + where = 'servicio_id = ?'; + whereArgs = [servicioId]; + } + final maps = await db.query('facturas', where: where, whereArgs: whereArgs, orderBy: 'fecha_vencimiento DESC'); + return maps.map((map) => Factura.fromMap(map)).toList(); + } + + Future> getFacturasVencidas() async { + final db = await database; + final hoy = DateTime.now().toIso8601String().split('T')[0]; + final maps = await db.query('facturas', + where: 'estado = ? AND fecha_vencimiento < ?', whereArgs: ['Pendiente', hoy], orderBy: 'fecha_vencimiento ASC'); + return maps.map((map) => Factura.fromMap(map)).toList(); + } + + Future> getFacturasPorMes(int year, int month) async { + final db = await database; + final mes = month.toString().padLeft(2, '0'); + final maps = await db.query('facturas', + where: "fecha_vencimiento LIKE ?", whereArgs: ['$year-$mes%'], orderBy: 'fecha_vencimiento ASC'); + return maps.map((map) => Factura.fromMap(map)).toList(); + } + + Future> getFacturasPorRango(String inicio, String fin) async { + final db = await database; + final maps = await db.query('facturas', + where: 'fecha_vencimiento BETWEEN ? AND ?', whereArgs: [inicio, fin], orderBy: 'fecha_vencimiento ASC'); + return maps.map((map) => Factura.fromMap(map)).toList(); + } + + Future getTotalGastadoMes(int year, int month) async { + final db = await database; + final mes = month.toString().padLeft(2, '0'); + final result = await db.rawQuery( + "SELECT COALESCE(SUM(monto), 0) as total FROM facturas WHERE estado = 'Pagada' AND fecha_pago LIKE ?", + ['$year-$mes%']); + return (result.first['total'] as num).toDouble(); + } + + Future> getGastosPorServicio(int year, int month) async { + final db = await database; + final mes = month.toString().padLeft(2, '0'); + final result = await db.rawQuery(''' + SELECT s.nombre, COALESCE(SUM(f.monto), 0) as total + FROM facturas f + JOIN servicios s ON f.servicio_id = s.id + WHERE f.estado = 'Pagada' AND f.fecha_pago LIKE ? + GROUP BY s.nombre + ''', ['$year-$mes%']); + final map = {}; + for (final row in result) { + map[row['nombre'] as String] = (row['total'] as num).toDouble(); + } + return map; + } + + Future>> getGastoMensual(int year) async { + final db = await database; + return await db.rawQuery(''' + SELECT strftime('%m', fecha_pago) as mes, COALESCE(SUM(monto), 0) as total + FROM facturas + WHERE estado = 'Pagada' AND fecha_pago LIKE ? + GROUP BY mes + ORDER BY mes + ''', ['$year-%']); + } + + Future updateFactura(Factura factura) async { + final db = await database; + return await db.update('facturas', factura.toMap(), where: 'id = ?', whereArgs: [factura.id]); + } + + Future deleteFactura(int id) async { + final db = await database; + return await db.delete('facturas', where: 'id = ?', whereArgs: [id]); + } + + Future getConfiguracion() async { + final db = await database; + final maps = await db.query('configuracion', limit: 1); + if (maps.isEmpty) return null; + return Configuracion.fromMap(maps.first); + } + + Future saveConfiguracion(Configuracion config) async { + final db = await database; + final existing = await getConfiguracion(); + if (existing != null) { + return await db.update('configuracion', config.toMap()..remove('id'), + where: 'id = ?', whereArgs: [existing.id]); + } + return await db.insert('configuracion', config.toMap()..remove('id')); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..a318bf1 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'providers/servicio_provider.dart'; +import 'providers/factura_provider.dart'; +import 'providers/configuracion_provider.dart'; +import 'screens/splash_screen.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => ServicioProvider()), + ChangeNotifierProvider(create: (_) => FacturaProvider()), + ChangeNotifierProvider(create: (_) => ConfiguracionProvider()), + ], + child: const HogarControlApp(), + ), + ); +} + +class HogarControlApp extends StatelessWidget { + const HogarControlApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'HogarControl', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorSchemeSeed: Colors.teal, + useMaterial3: true, + brightness: Brightness.light, + ), + home: const SplashScreen(), + ); + } +} diff --git a/lib/models/configuracion.dart b/lib/models/configuracion.dart new file mode 100644 index 0000000..4c6f0d8 --- /dev/null +++ b/lib/models/configuracion.dart @@ -0,0 +1,27 @@ +class Configuracion { + final int? id; + final String diasRecordatorio; + final String tema; + final String moneda; + + Configuracion({ + this.id, + required this.diasRecordatorio, + this.tema = 'claro', + this.moneda = 'USD', + }); + + Map toMap() => { + 'id': id, + 'dias_recordatorio': diasRecordatorio, + 'tema': tema, + 'moneda': moneda, + }; + + factory Configuracion.fromMap(Map map) => Configuracion( + id: map['id'] as int?, + diasRecordatorio: map['dias_recordatorio'] as String, + tema: map['tema'] as String? ?? 'claro', + moneda: map['moneda'] as String? ?? 'USD', + ); +} diff --git a/lib/models/factura.dart b/lib/models/factura.dart new file mode 100644 index 0000000..fbfdd68 --- /dev/null +++ b/lib/models/factura.dart @@ -0,0 +1,64 @@ +class Factura { + final int? id; + final int servicioId; + final double monto; + final String fechaEmision; + final String fechaVencimiento; + final String estado; + final String? nota; + final String? fechaPago; + + Factura({ + this.id, + required this.servicioId, + required this.monto, + required this.fechaEmision, + required this.fechaVencimiento, + this.estado = 'Pendiente', + this.nota, + this.fechaPago, + }); + + Factura copyWith({ + int? id, + int? servicioId, + double? monto, + String? fechaEmision, + String? fechaVencimiento, + String? estado, + String? nota, + String? fechaPago, + }) => + Factura( + id: id ?? this.id, + servicioId: servicioId ?? this.servicioId, + monto: monto ?? this.monto, + fechaEmision: fechaEmision ?? this.fechaEmision, + fechaVencimiento: fechaVencimiento ?? this.fechaVencimiento, + estado: estado ?? this.estado, + nota: nota ?? this.nota, + fechaPago: fechaPago ?? this.fechaPago, + ); + + Map toMap() => { + 'id': id, + 'servicio_id': servicioId, + 'monto': monto, + 'fecha_emision': fechaEmision, + 'fecha_vencimiento': fechaVencimiento, + 'estado': estado, + 'nota': nota, + 'fecha_pago': fechaPago, + }; + + factory Factura.fromMap(Map map) => Factura( + id: map['id'] as int?, + servicioId: map['servicio_id'] as int, + monto: (map['monto'] as num).toDouble(), + fechaEmision: map['fecha_emision'] as String, + fechaVencimiento: map['fecha_vencimiento'] as String, + estado: map['estado'] as String? ?? 'Pendiente', + nota: map['nota'] as String?, + fechaPago: map['fecha_pago'] as String?, + ); +} diff --git a/lib/models/servicio.dart b/lib/models/servicio.dart new file mode 100644 index 0000000..45b2823 --- /dev/null +++ b/lib/models/servicio.dart @@ -0,0 +1,35 @@ +class Servicio { + final int? id; + final String nombre; + final String icono; + final int color; + final int diaVencimiento; + final String fechaCreacion; + + Servicio({ + this.id, + required this.nombre, + required this.icono, + required this.color, + required this.diaVencimiento, + required this.fechaCreacion, + }); + + Map toMap() => { + 'id': id, + 'nombre': nombre, + 'icono': icono, + 'color': color, + 'dia_vencimiento': diaVencimiento, + 'fecha_creacion': fechaCreacion, + }; + + factory Servicio.fromMap(Map map) => Servicio( + id: map['id'] as int?, + nombre: map['nombre'] as String, + icono: map['icono'] as String, + color: map['color'] as int, + diaVencimiento: map['dia_vencimiento'] as int, + fechaCreacion: map['fecha_creacion'] as String, + ); +} diff --git a/lib/providers/configuracion_provider.dart b/lib/providers/configuracion_provider.dart new file mode 100644 index 0000000..cf1127c --- /dev/null +++ b/lib/providers/configuracion_provider.dart @@ -0,0 +1,40 @@ +import 'package:flutter/foundation.dart'; +import '../database/database_helper.dart'; +import '../models/configuracion.dart'; +import '../services/notification_service.dart'; + +class ConfiguracionProvider extends ChangeNotifier { + final DatabaseHelper _db = DatabaseHelper.instance; + Configuracion? _config; + bool _cargando = false; + + Configuracion? get config => _config; + bool get cargando => _cargando; + + Future cargarConfiguracion() async { + _cargando = true; + notifyListeners(); + _config = await _db.getConfiguracion(); + if (_config == null) { + _config = Configuracion(diasRecordatorio: '7,3,1,0'); + await _db.saveConfiguracion(_config!); + } + _cargando = false; + notifyListeners(); + } + + Future guardarConfiguracion(Configuracion config) async { + await _db.saveConfiguracion(config); + _config = config; + await NotificationService.instance.scheduleRecordatorios(); + notifyListeners(); + } + + List get diasRecordatorioList { + if (_config == null) return [7, 3, 1, 0]; + return _config!.diasRecordatorio + .split(',') + .map((e) => int.tryParse(e.trim()) ?? 0) + .toList(); + } +} diff --git a/lib/providers/factura_provider.dart b/lib/providers/factura_provider.dart new file mode 100644 index 0000000..c68c934 --- /dev/null +++ b/lib/providers/factura_provider.dart @@ -0,0 +1,61 @@ +import 'package:flutter/foundation.dart'; +import '../database/database_helper.dart'; +import '../models/factura.dart'; + +class FacturaProvider extends ChangeNotifier { + final DatabaseHelper _db = DatabaseHelper.instance; + List _facturas = []; + List _facturasVencidas = []; + bool _cargando = false; + + List get facturas => _facturas; + List get facturasVencidas => _facturasVencidas; + bool get cargando => _cargando; + + Future cargarFacturas({String? estado, int? servicioId}) async { + _cargando = true; + notifyListeners(); + _facturas = await _db.getFacturas(estado: estado, servicioId: servicioId); + _facturasVencidas = await _db.getFacturasVencidas(); + _cargando = false; + notifyListeners(); + } + + Future agregarFactura(Factura factura) async { + await _db.insertFactura(factura); + await cargarFacturas(); + } + + Future marcarPagada(int id) async { + final factura = _facturas.firstWhere((f) => f.id == id); + final hoy = DateTime.now().toIso8601String().split('T')[0]; + final actualizada = factura.copyWith(estado: 'Pagada', fechaPago: hoy); + await _db.updateFactura(actualizada); + await cargarFacturas(); + } + + Future eliminarFactura(int id) async { + await _db.deleteFactura(id); + await cargarFacturas(); + } + + Future totalGastadoMes(int year, int month) async { + return await _db.getTotalGastadoMes(year, month); + } + + Future> gastosPorServicio(int year, int month) async { + return await _db.getGastosPorServicio(year, month); + } + + Future>> gastoMensual(int year) async { + return await _db.getGastoMensual(year); + } + + Future> facturasPorMes(int year, int month) async { + return await _db.getFacturasPorMes(year, month); + } + + Future> facturasPorRango(String inicio, String fin) async { + return await _db.getFacturasPorRango(inicio, fin); + } +} diff --git a/lib/providers/servicio_provider.dart b/lib/providers/servicio_provider.dart new file mode 100644 index 0000000..2cd7555 --- /dev/null +++ b/lib/providers/servicio_provider.dart @@ -0,0 +1,35 @@ +import 'package:flutter/foundation.dart'; +import '../database/database_helper.dart'; +import '../models/servicio.dart'; + +class ServicioProvider extends ChangeNotifier { + final DatabaseHelper _db = DatabaseHelper.instance; + List _servicios = []; + bool _cargando = false; + + List get servicios => _servicios; + bool get cargando => _cargando; + + Future cargarServicios() async { + _cargando = true; + notifyListeners(); + _servicios = await _db.getServicios(); + _cargando = false; + notifyListeners(); + } + + Future agregarServicio(Servicio servicio) async { + await _db.insertServicio(servicio); + await cargarServicios(); + } + + Future actualizarServicio(Servicio servicio) async { + await _db.updateServicio(servicio); + await cargarServicios(); + } + + Future eliminarServicio(int id) async { + await _db.deleteServicio(id); + await cargarServicios(); + } +} diff --git a/lib/screens/calendario_screen.dart b/lib/screens/calendario_screen.dart new file mode 100644 index 0000000..b45b4b9 --- /dev/null +++ b/lib/screens/calendario_screen.dart @@ -0,0 +1,123 @@ +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'; + +class CalendarioScreen extends StatefulWidget { + const CalendarioScreen({super.key}); + + @override + State createState() => _CalendarioScreenState(); +} + +class _CalendarioScreenState extends State { + DateTime _focusedDay = DateTime.now(); + DateTime? _selectedDay; + Map>> _eventos = {}; + + @override + void initState() { + super.initState(); + _cargarEventos(); + } + + void _cargarEventos() { + final facturaProv = context.read(); + final servicioProv = context.read(); + facturaProv.cargarFacturas().then((_) { + _construirEventos(facturaProv, servicioProv); + }); + } + + void _construirEventos(FacturaProvider facturaProv, ServicioProvider servicioProv) { + final eventos = >>{}; + for (final f in facturaProv.facturas) { + final fecha = DateTime.tryParse(f.fechaVencimiento); + if (fecha == null) continue; + final servicio = servicioProv.servicios.where((s) => s.id == f.servicioId).firstOrNull; + final day = DateTime(fecha.year, fecha.month, fecha.day); + eventos.putIfAbsent(day, () => []).add({ + 'servicio': servicio?.nombre ?? 'Desconocido', + 'monto': f.monto, + 'estado': f.estado, + 'color': Color(servicio?.color ?? 0xFF6200EE), + }); + } + setState(() => _eventos = eventos); + } + + @override + Widget build(BuildContext context) { + context.watch(); + context.watch(); + final configProv = context.watch(); + final symbol = configProv.config?.moneda == 'USD' ? '\$' : configProv.config?.moneda == 'EUR' ? '€' : 'MX\$'; + + return Scaffold( + appBar: AppBar(title: const Text('Calendario de pagos')), + body: Column( + children: [ + TableCalendar( + firstDay: DateTime(2024), + lastDay: DateTime(2035), + focusedDay: _focusedDay, + selectedDayPredicate: (day) => isSameDay(_selectedDay, day), + eventLoader: (day) { + final d = DateTime(day.year, day.month, day.day); + return _eventos[d] ?? []; + }, + calendarStyle: CalendarStyle( + todayDecoration: BoxDecoration(color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), shape: BoxShape.circle), + selectedDecoration: BoxDecoration(color: Theme.of(context).colorScheme.primary, shape: BoxShape.circle), + markerDecoration: BoxDecoration(color: Theme.of(context).colorScheme.secondary, shape: BoxShape.circle), + ), + headerStyle: const HeaderStyle(formatButtonVisible: false, titleCentered: true), + onDaySelected: (selected, focused) { + setState(() { + _selectedDay = selected; + _focusedDay = focused; + }); + }, + onPageChanged: (focused) => _focusedDay = focused, + calendarBuilders: CalendarBuilders( + markerBuilder: (context, date, events) { + if (events.isEmpty) return null; + final pendientes = events.where((e) => (e as Map)['estado'] == 'Pendiente').length; + final pagadas = events.where((e) => (e as Map)['estado'] == 'Pagada').length; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (pendientes > 0) + Container(margin: const EdgeInsets.only(right: 2), width: 6, height: 6, decoration: BoxDecoration(color: Colors.red, shape: BoxShape.circle)), + if (pagadas > 0) + Container(width: 6, height: 6, decoration: BoxDecoration(color: Colors.green, shape: BoxShape.circle)), + ], + ); + }, + ), + ), + const Divider(), + 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))), + ), + ); + }).toList(), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/configuracion_screen.dart b/lib/screens/configuracion_screen.dart new file mode 100644 index 0000000..fbb86da --- /dev/null +++ b/lib/screens/configuracion_screen.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/configuracion.dart'; +import '../providers/configuracion_provider.dart'; + +class ConfiguracionScreen extends StatefulWidget { + const ConfiguracionScreen({super.key}); + + @override + State createState() => _ConfiguracionScreenState(); +} + +class _ConfiguracionScreenState extends State { + late List _diasSeleccionados; + late String _moneda; + + final List> _opcionesDias = [ + {'label': '7 días antes', 'value': 7}, + {'label': '3 días antes', 'value': 3}, + {'label': '1 día antes', 'value': 1}, + {'label': 'Día de vencimiento', 'value': 0}, + ]; + + final List _monedas = ['USD', 'EUR', 'MXN', 'COP', 'ARS', 'PEN', 'CLP', 'BRL']; + + @override + void initState() { + super.initState(); + final config = context.read().config; + final dias = config?.diasRecordatorio ?? '7,3,1,0'; + final lista = dias.split(',').map((e) => int.tryParse(e.trim()) ?? 0).toList(); + _diasSeleccionados = _opcionesDias.map((o) => lista.contains(o['value'])).toList(); + _moneda = config?.moneda ?? 'USD'; + } + + @override + Widget build(BuildContext context) { + final provider = context.read(); + + return Scaffold( + appBar: AppBar(title: const Text('Configuración')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text('Recordatorios', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + ...List.generate(_opcionesDias.length, (i) { + return CheckboxListTile( + title: Text(_opcionesDias[i]['label'] as String), + value: _diasSeleccionados[i], + onChanged: (v) { + setState(() => _diasSeleccionados[i] = v ?? false); + }, + ); + }), + const SizedBox(height: 20), + Text('Moneda', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _moneda, + decoration: const InputDecoration(border: OutlineInputBorder()), + items: _monedas.map((m) => DropdownMenuItem(value: m, child: Text(m))).toList(), + onChanged: (v) => setState(() => _moneda = v!), + ), + const SizedBox(height: 32), + FilledButton( + onPressed: () async { + final diasStr = _opcionesDias + .asMap() + .entries + .where((e) => _diasSeleccionados[e.key]) + .map((e) => e.value['value'].toString()) + .join(','); + final config = Configuracion( + id: provider.config?.id, + diasRecordatorio: diasStr.isEmpty ? '0' : diasStr, + tema: provider.config?.tema ?? 'claro', + moneda: _moneda, + ); + await provider.guardarConfiguracion(config); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Configuración guardada')), + ); + } + }, + child: const Text('Guardar configuración'), + ), + ], + ), + ); + } +} diff --git a/lib/screens/crear_factura_screen.dart b/lib/screens/crear_factura_screen.dart new file mode 100644 index 0000000..3a3e840 --- /dev/null +++ b/lib/screens/crear_factura_screen.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; +import '../models/factura.dart'; +import '../providers/factura_provider.dart'; +import '../providers/servicio_provider.dart'; + +class CrearFacturaScreen extends StatefulWidget { + const CrearFacturaScreen({super.key}); + + @override + State createState() => _CrearFacturaScreenState(); +} + +class _CrearFacturaScreenState extends State { + final _formKey = GlobalKey(); + late TextEditingController _montoCtrl; + late TextEditingController _notaCtrl; + int? _servicioId; + DateTime _fechaEmision = DateTime.now(); + DateTime _fechaVencimiento = DateTime.now().add(const Duration(days: 30)); + String _estado = 'Pendiente'; + + @override + void initState() { + super.initState(); + _montoCtrl = TextEditingController(); + _notaCtrl = TextEditingController(); + context.read().cargarServicios(); + } + + @override + void dispose() { + _montoCtrl.dispose(); + _notaCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final servicioProv = context.watch(); + final facturaProv = context.read(); + + return Scaffold( + appBar: AppBar(title: const Text('Nueva factura')), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + DropdownButtonFormField( + value: _servicioId, + decoration: const InputDecoration(labelText: 'Servicio', border: OutlineInputBorder()), + items: servicioProv.servicios.map((s) => DropdownMenuItem(value: s.id, child: Text(s.nombre))).toList(), + onChanged: (v) => setState(() => _servicioId = v), + validator: (v) => v == null ? 'Selecciona un servicio' : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _montoCtrl, + decoration: const InputDecoration(labelText: 'Monto', border: OutlineInputBorder(), prefixText: '\$ '), + keyboardType: TextInputType.numberWithOptions(decimal: true), + validator: (v) { + if (v == null || v.isEmpty) return 'Ingresa el monto'; + if (double.tryParse(v) == null) return 'Monto inválido'; + return null; + }, + ), + const SizedBox(height: 16), + ListTile( + title: const Text('Fecha de emisión'), + subtitle: Text(DateFormat('yyyy-MM-dd').format(_fechaEmision)), + trailing: const Icon(Icons.calendar_today), + onTap: () async { + final date = await showDatePicker(context: context, firstDate: DateTime(2020), lastDate: DateTime(2035), initialDate: _fechaEmision); + if (date != null) setState(() => _fechaEmision = date); + }, + ), + ListTile( + title: const Text('Fecha de vencimiento'), + subtitle: Text(DateFormat('yyyy-MM-dd').format(_fechaVencimiento)), + trailing: const Icon(Icons.calendar_today), + onTap: () async { + final date = await showDatePicker(context: context, firstDate: DateTime(2020), lastDate: DateTime(2035), initialDate: _fechaVencimiento); + if (date != null) setState(() => _fechaVencimiento = date); + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _notaCtrl, + decoration: const InputDecoration(labelText: 'Nota (opcional)', border: OutlineInputBorder()), + maxLines: 2, + ), + const SizedBox(height: 16), + SegmentedButton( + segments: const [ + ButtonSegment(value: 'Pendiente', label: Text('Pendiente')), + ButtonSegment(value: 'Pagada', label: Text('Pagada')), + ], + selected: {_estado}, + onSelectionChanged: (v) => setState(() => _estado = v.first), + ), + const SizedBox(height: 32), + FilledButton( + onPressed: () async { + if (!_formKey.currentState!.validate()) return; + final factura = Factura( + 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, + ); + await facturaProv.agregarFactura(factura); + if (context.mounted) Navigator.pop(context); + }, + child: const Text('Guardar factura'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/crear_servicio_screen.dart b/lib/screens/crear_servicio_screen.dart new file mode 100644 index 0000000..460bdcc --- /dev/null +++ b/lib/screens/crear_servicio_screen.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; +import '../models/servicio.dart'; +import '../providers/servicio_provider.dart'; + +class CrearServicioScreen extends StatefulWidget { + final Servicio? servicio; + const CrearServicioScreen({super.key, this.servicio}); + + @override + State createState() => _CrearServicioScreenState(); +} + +class _CrearServicioScreenState extends State { + final _formKey = GlobalKey(); + late TextEditingController _nombreCtrl; + String _iconoSeleccionado = 'recibo'; + Color _colorSeleccionado = Colors.purple; + int _diaVencimiento = 15; + + final List> _iconos = [ + {'key': 'electricidad', 'icon': Icons.bolt, 'label': 'Electricidad'}, + {'key': 'agua', 'icon': Icons.water_drop, 'label': 'Agua'}, + {'key': 'internet', 'icon': Icons.wifi, 'label': 'Internet'}, + {'key': 'cable', 'icon': Icons.tv, 'label': 'Cable'}, + {'key': 'telefono', 'icon': Icons.phone, 'label': 'Teléfono'}, + {'key': 'gas', 'icon': Icons.local_fire_department, 'label': 'Gas'}, + {'key': 'alquiler', 'icon': Icons.home, 'label': 'Alquiler'}, + {'key': 'recibo', 'icon': Icons.receipt, 'label': 'Otros'}, + ]; + + final List _colores = [ + Colors.red, Colors.pink, Colors.purple, Colors.deepPurple, + Colors.indigo, Colors.blue, Colors.lightBlue, Colors.cyan, + Colors.teal, Colors.green, Colors.lightGreen, Colors.lime, + Colors.yellow, Colors.amber, Colors.orange, Colors.deepOrange, + Colors.brown, Colors.grey, Colors.blueGrey, + ]; + + @override + void initState() { + super.initState(); + _nombreCtrl = TextEditingController(text: widget.servicio?.nombre ?? ''); + if (widget.servicio != null) { + _iconoSeleccionado = widget.servicio!.icono; + _colorSeleccionado = Color(widget.servicio!.color); + _diaVencimiento = widget.servicio!.diaVencimiento; + } + } + + @override + void dispose() { + _nombreCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final provider = context.read(); + + return Scaffold( + appBar: AppBar(title: Text(widget.servicio != null ? 'Editar servicio' : 'Nuevo servicio')), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextFormField( + controller: _nombreCtrl, + decoration: const InputDecoration(labelText: 'Nombre del servicio', border: OutlineInputBorder()), + validator: (v) => v == null || v.trim().isEmpty ? 'Ingresa el nombre' : null, + ), + const SizedBox(height: 20), + Text('Icono', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: _iconos.map((item) { + final selected = _iconoSeleccionado == item['key']; + return ChoiceChip( + selected: selected, + avatar: Icon(item['icon'] as IconData, size: 20), + label: Text(item['label'] as String, style: const TextStyle(fontSize: 12)), + onSelected: (_) => setState(() => _iconoSeleccionado = item['key'] as String), + ); + }).toList(), + ), + const SizedBox(height: 20), + Text('Color', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: _colores.map((c) { + final selected = _colorSeleccionado == c; + return GestureDetector( + onTap: () => setState(() => _colorSeleccionado = c), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: c, + shape: BoxShape.circle, + border: selected ? Border.all(color: Colors.white, width: 3) : null, + boxShadow: selected ? [BoxShadow(color: c.withValues(alpha: 0.5), blurRadius: 8)] : null, + ), + child: selected ? const Icon(Icons.check, color: Colors.white, size: 18) : null, + ), + ); + }).toList(), + ), + const SizedBox(height: 20), + Text('Día de vencimiento', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _diaVencimiento, + decoration: const InputDecoration(border: OutlineInputBorder()), + items: List.generate(28, (i) => DropdownMenuItem(value: i + 1, child: Text('${i + 1}'))), + onChanged: (v) => setState(() => _diaVencimiento = v!), + ), + const SizedBox(height: 32), + FilledButton( + onPressed: () async { + if (!_formKey.currentState!.validate()) return; + final servicio = Servicio( + id: widget.servicio?.id, + nombre: _nombreCtrl.text.trim(), + icono: _iconoSeleccionado, + color: _colorSeleccionado.value, + diaVencimiento: _diaVencimiento, + fechaCreacion: widget.servicio?.fechaCreacion ?? DateFormat('yyyy-MM-dd').format(DateTime.now()), + ); + if (widget.servicio != null) { + await provider.actualizarServicio(servicio); + } else { + await provider.agregarServicio(servicio); + } + if (context.mounted) Navigator.pop(context); + }, + child: Text(widget.servicio != null ? 'Guardar cambios' : 'Crear servicio'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart new file mode 100644 index 0000000..5066f87 --- /dev/null +++ b/lib/screens/dashboard_screen.dart @@ -0,0 +1,142 @@ +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 'servicios_screen.dart'; +import 'facturas_screen.dart'; +import 'calendario_screen.dart'; +import 'estadisticas_screen.dart'; +import 'configuracion_screen.dart'; +import 'crear_factura_screen.dart'; + +class DashboardScreen extends StatefulWidget { + const DashboardScreen({super.key}); + + @override + State createState() => _DashboardScreenState(); +} + +class _DashboardScreenState extends State { + @override + void initState() { + super.initState(); + _cargarDatos(); + } + + void _cargarDatos() { + context.read().cargarServicios(); + context.read().cargarFacturas(); + } + + @override + Widget build(BuildContext context) { + final facturaProv = context.watch(); + 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: [ + IconButton(icon: const Icon(Icons.settings), onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ConfiguracionScreen()))), + ]), + body: RefreshIndicator( + onRefresh: () async => _cargarDatos(), + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Expanded(child: StatCard( + title: 'Pendientes', + value: '$pendientes', + icon: Icons.pending_actions, + color: Colors.orange, + )), + const SizedBox(width: 12), + Expanded(child: StatCard( + title: 'Vencidas', + value: '$vencidas', + icon: Icons.warning_rounded, + color: Colors.red, + )), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded(child: StatCard( + title: 'Pagadas este mes', + value: facturaProv.facturas.where((f) => f.estado == 'Pagada').length.toString(), + icon: Icons.check_circle, + color: Colors.green, + )), + 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))}', + icon: Icons.attach_money, + color: Colors.blue, + )), + ], + ), + const SizedBox(height: 20), + Text('Próximos vencimientos', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + ...facturaProv.facturas + .where((f) => f.estado == 'Pendiente') + .take(5) + .map((f) { + 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'), + ), + ), + ); + }), + ], + ), + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const CrearFacturaScreen())).then((_) => _cargarDatos()), + icon: const Icon(Icons.add), + label: const Text('Nueva factura'), + ), + bottomNavigationBar: NavigationBar( + selectedIndex: 0, + onDestinationSelected: (i) { + switch (i) { + case 0: break; + case 1: Navigator.push(context, MaterialPageRoute(builder: (_) => const ServiciosScreen())); + case 2: Navigator.push(context, MaterialPageRoute(builder: (_) => const FacturasScreen())); + case 3: Navigator.push(context, MaterialPageRoute(builder: (_) => const CalendarioScreen())); + case 4: Navigator.push(context, MaterialPageRoute(builder: (_) => const EstadisticasScreen())); + } + }, + destinations: const [ + NavigationDestination(icon: Icon(Icons.dashboard), label: 'Dashboard'), + NavigationDestination(icon: Icon(Icons.electrical_services), label: 'Servicios'), + NavigationDestination(icon: Icon(Icons.receipt_long), label: 'Facturas'), + NavigationDestination(icon: Icon(Icons.calendar_month), label: 'Calendario'), + NavigationDestination(icon: Icon(Icons.bar_chart), label: 'Stats'), + ], + ), + ); + } +} diff --git a/lib/screens/estadisticas_screen.dart b/lib/screens/estadisticas_screen.dart new file mode 100644 index 0000000..23c37e7 --- /dev/null +++ b/lib/screens/estadisticas_screen.dart @@ -0,0 +1,145 @@ +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'; + +class EstadisticasScreen extends StatefulWidget { + const EstadisticasScreen({super.key}); + + @override + State createState() => _EstadisticasScreenState(); +} + +class _EstadisticasScreenState extends State { + late int _year; + + @override + void initState() { + super.initState(); + _year = DateTime.now().year; + } + + @override + Widget build(BuildContext context) { + 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')), + body: FutureBuilder( + future: Future.wait([ + facturaProv.gastoMensual(_year), + facturaProv.gastosPorServicio(_year, DateTime.now().month), + facturaProv.totalGastadoMes(_year, DateTime.now().month), + ]), + builder: (context, AsyncSnapshot> snapshot) { + if (!snapshot.hasData) return const Center(child: CircularProgressIndicator()); + + final gastoMensual = snapshot.data![0] as List>; + final gastoPorServicio = snapshot.data![1] as Map; + final totalMes = snapshot.data![2] as double; + + return ListView( + padding: const EdgeInsets.all(16), + 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), + ], + ), + ), + ), + const SizedBox(height: 20), + Text('Gasto mensual ($_year)', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + SizedBox( + height: 200, + child: BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: (gastoMensual.isEmpty ? 1000 : gastoMensual.map((e) => (e['total'] as num).toDouble()).reduce((a, b) => a > b ? a : b)) * 1.2, + barGroups: List.generate(12, (i) { + final mes = (i + 1).toString().padLeft(2, '0'); + final data = gastoMensual.where((e) => e['mes'] == mes).firstOrNull; + return BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: data != null ? (data['total'] as num).toDouble() : 0, + color: Theme.of(context).colorScheme.primary, + width: 16, + borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), + ), + ], + ); + }), + titlesData: FlTitlesData( + leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true, reservedSize: 40, getTitlesWidget: (v, _) => Text('$symbol${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)); + })), + rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + gridData: FlGridData(show: true, drawVerticalLine: false, horizontalInterval: (gastoMensual.isEmpty ? 1000 : gastoMensual.map((e) => (e['total'] as num).toDouble()).reduce((a, b) => a > b ? a : b)) / 4), + borderData: FlBorderData(show: false), + ), + ), + ), + const SizedBox(height: 20), + Text('Distribución por servicio', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + if (gastoPorServicio.isEmpty) + Card(child: Padding(padding: const EdgeInsets.all(16), child: Text('Sin datos este mes'))) + else + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: gastoPorServicio.entries.map((e) { + 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, + ), + ), + ), + const SizedBox(width: 8), + SizedBox(width: 70, child: Text('$symbol${NumberFormat('#,##0.00').format(e.value)}', style: const TextStyle(fontSize: 12), textAlign: TextAlign.right)), + ], + ), + ); + }).toList(), + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/screens/facturas_screen.dart b/lib/screens/facturas_screen.dart new file mode 100644 index 0000000..aadace0 --- /dev/null +++ b/lib/screens/facturas_screen.dart @@ -0,0 +1,110 @@ +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'; + +class FacturasScreen extends StatefulWidget { + const FacturasScreen({super.key}); + + @override + State createState() => _FacturasScreenState(); +} + +class _FacturasScreenState extends State { + String? _filtroEstado; + int? _filtroServicio; + + @override + void initState() { + super.initState(); + context.read().cargarFacturas(); + } + + @override + Widget build(BuildContext context) { + final facturaProv = context.watch(); + 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')), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + FilterChip(label: const Text('Todas'), selected: _filtroEstado == null && _filtroServicio == null, onSelected: (_) => setState(() { _filtroEstado = null; _filtroServicio = null; facturaProv.cargarFacturas(); })), + const SizedBox(width: 8), + FilterChip(label: const Text('Pendientes'), selected: _filtroEstado == 'Pendiente', onSelected: (_) => setState(() { _filtroEstado = 'Pendiente'; _filtroServicio = null; facturaProv.cargarFacturas(estado: 'Pendiente'); })), + 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; })), + ], + ), + ), + ), + Expanded( + child: facturaProv.cargando + ? const Center(child: CircularProgressIndicator()) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: facturaProv.facturas.length + (facturaProv.facturas.isEmpty ? 1 : 0), + itemBuilder: (_, i) { + if (facturaProv.facturas.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(48), + child: Column( + children: [ + Icon(Icons.receipt_long, size: 64, color: Colors.grey[400]), + const SizedBox(height: 16), + Text('No hay facturas', style: Theme.of(context).textTheme.bodyLarge), + ], + ), + ), + ); + } + 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') + IconButton( + icon: Icon(Icons.check_circle, color: vencida ? Colors.red : Colors.green), + onPressed: () => facturaProv.marcarPagada(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 new file mode 100644 index 0000000..3ed5ff8 --- /dev/null +++ b/lib/screens/servicios_screen.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/servicio_provider.dart'; +import 'crear_servicio_screen.dart'; + +class ServiciosScreen extends StatelessWidget { + const ServiciosScreen({super.key}); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + return Scaffold( + appBar: AppBar(title: const Text('Servicios')), + body: provider.cargando + ? const Center(child: CircularProgressIndicator()) + : provider.servicios.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.electrical_services, size: 64, color: Colors.grey[400]), + const SizedBox(height: 16), + Text('No hay servicios registrados', style: Theme.of(context).textTheme.bodyLarge), + const SizedBox(height: 8), + Text('Agrega tu primer servicio', style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.all(16), + 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), + ), + 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()), + ), + ); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const CrearServicioScreen())).then((_) => provider.cargarServicios()), + child: const Icon(Icons.add), + ), + ); + } + + IconData _getIcon(String name) { + switch (name) { + case 'electricidad': return Icons.bolt; + case 'agua': return Icons.water_drop; + case 'internet': return Icons.wifi; + case 'cable': return Icons.tv; + case 'telefono': return Icons.phone; + case 'gas': return Icons.local_fire_department; + case 'alquiler': return Icons.home; + default: return Icons.receipt; + } + } +} diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart new file mode 100644 index 0000000..dc99f94 --- /dev/null +++ b/lib/screens/splash_screen.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/configuracion_provider.dart'; +import '../services/notification_service.dart'; +import 'dashboard_screen.dart'; + +class SplashScreen extends StatefulWidget { + const SplashScreen({super.key}); + + @override + State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State { + @override + void initState() { + super.initState(); + _init(); + } + + Future _init() async { + await NotificationService.instance.init(); + await context.read().cargarConfiguracion(); + await NotificationService.instance.scheduleRecordatorios(); + if (mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const DashboardScreen()), + ); + } + } + + @override + Widget build(BuildContext context) { + 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), + ], + ), + ), + ); + } +} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart new file mode 100644 index 0000000..56339e1 --- /dev/null +++ b/lib/services/notification_service.dart @@ -0,0 +1,74 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:timezone/timezone.dart' as tz; +import 'package:timezone/data/latest.dart' as tz_data; +import '../database/database_helper.dart'; + +class NotificationService { + static final NotificationService instance = NotificationService._init(); + final FlutterLocalNotificationsPlugin _plugin = FlutterLocalNotificationsPlugin(); + bool _initialized = false; + + NotificationService._init(); + + Future init() async { + if (_initialized) return; + tz_data.initializeTimeZones(); + const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); + const settings = InitializationSettings(android: androidSettings); + await _plugin.initialize(settings); + _initialized = true; + } + + Future scheduleRecordatorios() async { + await _plugin.cancelAll(); + final db = DatabaseHelper.instance; + final facturas = await db.getFacturas(estado: 'Pendiente'); + final config = await db.getConfiguracion(); + final diasStr = config?.diasRecordatorio ?? '7,3,1,0'; + final dias = diasStr.split(',').map((e) => int.tryParse(e.trim()) ?? 0).toList(); + + for (final factura in facturas) { + final vencimiento = DateTime.tryParse(factura.fechaVencimiento); + if (vencimiento == null) continue; + for (final dia in dias) { + final notifDate = vencimiento.subtract(Duration(days: dia)); + final now = DateTime.now(); + if (notifDate.isAfter(now) || notifDate.day == now.day) { + await _scheduleNotification( + id: factura.id! * 10 + dia, + title: dia == 0 ? 'Factura vence hoy' : 'Recordatorio de factura', + body: 'La factura vence en $dia día(s)', + scheduledDate: notifDate, + ); + } + } + } + } + + Future _scheduleNotification({ + required int id, + required String title, + required String body, + required DateTime scheduledDate, + }) async { + const androidDetails = AndroidNotificationDetails( + 'hogarcontrol_recordatorios', + 'Recordatorios de facturas', + channelDescription: 'Notificaciones de recordatorio de pago', + importance: Importance.high, + priority: Priority.high, + ); + const details = NotificationDetails(android: androidDetails); + final tzDate = tz.TZDateTime.from(scheduledDate, tz.local); + await _plugin.zonedSchedule( + id, + title, + body, + tzDate, + details, + androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, + matchDateTimeComponents: DateTimeComponents.dayOfMonthAndTime, + uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, + ); + } +} diff --git a/lib/widgets/stat_card.dart b/lib/widgets/stat_card.dart new file mode 100644 index 0000000..2939ff4 --- /dev/null +++ b/lib/widgets/stat_card.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class StatCard extends StatelessWidget { + final String title; + final String value; + final IconData icon; + final Color color; + + const StatCard({ + super.key, + required this.title, + required this.value, + required this.icon, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 8), + Text(title, style: Theme.of(context).textTheme.bodySmall), + ], + ), + const SizedBox(height: 8), + Text(value, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), + ], + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..6143a57 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,493 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + url: "https://pub.dev" + source: hosted + version: "1.19.0" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08" + url: "https://pub.dev" + source: hosted + version: "0.69.2" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610 + url: "https://pub.dev" + source: hosted + version: "18.0.1" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52" + url: "https://pub.dev" + source: hosted + version: "8.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + url: "https://pub.dev" + source: hosted + version: "10.0.7" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + url: "https://pub.dev" + source: hosted + version: "3.0.8" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" + source: hosted + version: "1.15.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + path: + dependency: "direct main" + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" + source: hosted + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + simple_gesture_detector: + dependency: transitive + description: + name: simple_gesture_detector + sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 + url: "https://pub.dev" + source: hosted + version: "0.2.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" + url: "https://pub.dev" + source: hosted + version: "2.5.4+6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" + url: "https://pub.dev" + source: hosted + version: "2.4.1+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + url: "https://pub.dev" + source: hosted + version: "1.12.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + url: "https://pub.dev" + source: hosted + version: "3.3.0+3" + table_calendar: + dependency: "direct main" + description: + name: table_calendar + sha256: b2896b7c86adf3a4d9c911d860120fe3dbe03c85db43b22fd61f14ee78cdbb63 + url: "https://pub.dev" + source: hosted + version: "3.1.3" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + url: "https://pub.dev" + source: hosted + version: "0.7.3" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + url: "https://pub.dev" + source: hosted + version: "14.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" +sdks: + dart: ">=3.6.2 <4.0.0" + flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..efac8aa --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,95 @@ +name: hogarcontrol +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.6.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + sqflite: ^2.4.1 + path_provider: ^2.1.5 + path: ^1.9.0 + intl: ^0.19.0 + flutter_local_notifications: ^18.0.1 + timezone: ^0.10.1 + table_calendar: ^3.1.3 + fl_chart: ^0.69.2 + provider: ^6.1.2 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..1c41cba --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:hogarcontrol/main.dart'; +import 'package:hogarcontrol/providers/servicio_provider.dart'; +import 'package:hogarcontrol/providers/factura_provider.dart'; +import 'package:hogarcontrol/providers/configuracion_provider.dart'; + +void main() { + testWidgets('App shows splash screen', (WidgetTester tester) async { + await tester.pumpWidget(MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => ServicioProvider()), + ChangeNotifierProvider(create: (_) => FacturaProvider()), + ChangeNotifierProvider(create: (_) => ConfiguracionProvider()), + ], + child: const HogarControlApp(), + )); + + expect(find.text('HogarControl'), findsOneWidget); + }); +}