Agrega PRIVACY.md, actualiza AGENTS.md

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

View File

@@ -1,48 +1,55 @@
# HogarControl # Factura del Hogar
Flutter app for tracking home bills and recurring expenses. All data stored locally via SQLite — no internet required, no accounts. Flutter app for tracking home bills and recurring expenses. All data stored locally via SQLite — no internet, no accounts.
## Build & install ## Build & install
```bash ```bash
export JAVA_HOME=/root/.sdkman/candidates/java/21.0.11-amzn export JAVA_HOME=/root/.sdkman/candidates/java/21.0.11-amzn
flutter pub get /opt/flutter/bin/flutter pub get
flutter build apk --debug /opt/flutter/bin/flutter build apk --debug
adb install build/app/outputs/flutter-apk/app-debug.apk /opt/android-sdk/platform-tools/adb install build/app/outputs/flutter-apk/app-debug.apk
``` ```
## Prerequisites ## Prerequisites
- **JDK 21** via SDKMAN: `sdk use java 21.0.11-amzn` - **JDK 21** via SDKMAN: `sdk use java 21.0.11-amzn`
- **Android SDK** at `/opt/android-sdk` (set via `flutter config --android-sdk /opt/android-sdk`) - **Android SDK** at `/opt/android-sdk`
- **Flutter 3.27.4** at `/opt/flutter/bin` - **Flutter 3.27.4** at `/opt/flutter/bin`
- NDK `27.0.12077973` (set in `android/app/build.gradle`) - **adb** at `/opt/android-sdk/platform-tools/adb` (not in PATH)
- NDK `27.0.12077973` in `android/app/build.gradle`
## Key config ## Verify
- `android/app/build.gradle` — compileSdk 35, minSdk 26, targetSdk 35, coreLibraryDesugaringEnabled, AGP 8.7.3 ```bash
- `android/settings.gradle` — AGP + Kotlin plugin versions /opt/flutter/bin/dart analyze
- Permissions: `INTERNET`, `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, `SCHEDULE_EXACT_ALARM` export JAVA_HOME=/root/.sdkman/candidates/java/21.0.11-amzn && /opt/flutter/bin/flutter build apk --debug
```
No test suite configured beyond the default boilerplate (`test/widget_test.dart`). Lint shows 3 pre-existing issues (unused import in test file, deprecated `value` on Color, async BuildContext) — safe to ignore.
## Stack ## Stack
- **State**: Provider (`ChangeNotifierProvider` for `Servicio`, `Factura`, `Configuracion`) - **State**: Provider (`ChangeNotifierProvider` for `Servicio`, `Factura`, `Configuracion`)
- **DB**: sqflite (single DB `hogarcontrol.db`, tables: `servicios`, `facturas`, `configuracion`) - **DB**: sqflite (single `hogarcontrol.db`, tables: `servicios`, `facturas`, `configuracion`)
- **Notifications**: `flutter_local_notifications` with `zonedSchedule` + timezone - **Notifications**: `flutter_local_notifications` with `zonedSchedule` + timezone
- **Charts**: fl_chart (bar chart) + table_calendar - **Charts**: fl_chart (bar chart) + table_calendar
- **UI**: Material 3, no router (manual Navigator pushes) - **UI**: Material 3, `ThemeMode.system` (light/dark via system), custom `SmoothPageTransitionBuilder` (fade+slide on all pushes)
- **Navigation**: manual `Navigator.push` with `MaterialPageRoute` — no router
## Project layout ## Project layout
| Path | Purpose | | Path | Purpose |
|---|---| |---|---|
| `lib/main.dart` | App entry, MultiProvider setup, theme | | `lib/main.dart` | Entry, MultiProvider, `_buildLightTheme()` / `_buildDarkTheme()` |
| `lib/models/` | Data classes (`Servicio`, `Factura`, `Configuracion`) | | `lib/models/` | Data classes (`Servicio`, `Factura`, `Configuracion`) |
| `lib/database/database_helper.dart` | Singleton DB with CRUD helpers | | `lib/database/database_helper.dart` | Singleton DB with CRUD helpers |
| `lib/providers/` | ChangeNotifier state classes | | `lib/providers/` | ChangeNotifier state classes |
| `lib/screens/` | 9 screens (Splash → Dashboard → ...) | | `lib/screens/` | 9 screens (Splash → Dashboard → ...) |
| `lib/services/notification_service.dart` | Local notification scheduling | | `lib/services/notification_service.dart` | Local notification scheduling |
| `lib/widgets/` | Reusable UI components | | `lib/widgets/` | `StatCard` (animated fade+slide on mount) |
| `lib/utils/currency_utils.dart` | 37 currencies with `formatPrice()`, `getSymbol()`, `getDisplayName()` |
| `lib/utils/transitions.dart` | `SmoothPageTransitionBuilder` for global page transitions |
## DB schema ## DB schema
@@ -50,8 +57,37 @@ adb install build/app/outputs/flutter-apk/app-debug.apk
- `facturas` — id, servicio_id (FK), monto, fecha_emision, fecha_vencimiento, estado, nota, fecha_pago - `facturas` — id, servicio_id (FK), monto, fecha_emision, fecha_vencimiento, estado, nota, fecha_pago
- `configuracion` — id, dias_recordatorio (comma-separated), tema, moneda - `configuracion` — id, dias_recordatorio (comma-separated), tema, moneda
## Key Android config
- `android/app/build.gradle` — compileSdk 35, minSdk 26, targetSdk 35, coreLibraryDesugaringEnabled, AGP 8.7.3
- `android/settings.gradle` — AGP 8.7.3, Kotlin 2.0.21
- Permissions (main manifest): `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, `SCHEDULE_EXACT_ALARM`
- `INTERNET` only in debug/profile manifests (Flutter dev requirement — not in release)
## Currency helper
Always use `CurrencyUtils` instead of hardcoding symbols:
- `CurrencyUtils.formatPrice(amount, code)` — returns e.g. `$1,234.56` or `€1.234,56`
- `CurrencyUtils.getSymbol(code)` — symbol only
- `CurrencyUtils.getDisplayName(code)` — e.g. `"USD - US Dollar"`
- Zero-decimal currencies (JPY, KRW, CLP, PYG, COP) are handled automatically
- List: `CurrencyUtils.allCurrencies` (37 currencies)
## Styling conventions
- All cards use `BorderRadius.circular(16)` via `CardTheme`
- Input fields use `filled: true` with `BorderRadius.circular(12)`
- Page transitions: fade + slight vertical slide (defined in `transitions.dart`)
- List items animate in with `TweenAnimationBuilder<double>` using staggered delays (`300 + i * 60` ms pattern)
- `StatCard` is a `StatefulWidget` with `SingleTickerProviderStateMixin` — delay via `delayMs` prop
## Warnings ## Warnings
- `dart analyze` may flag unused imports/variables in screens — safe to ignore for widget-tree watchers - `dart analyze` flags unused imports/variables in screens — safe to ignore for widget-tree watchers
- Running as root causes flutter warnings but doesn't break builds - Running as root causes flutter warnings but doesn't break builds
- Flutter 3.27.4 has known warnings about x86 target deprecation - Flutter 3.27.4 has known warnings about x86 target deprecation
## Remote
- Origin: `https://git.devlab.lat/android/home-control.git`
- Credentials: `agent` + token `b453c4526d9b4857f1263c9a80514488`

37
PRIVACY.md Normal file
View File

@@ -0,0 +1,37 @@
# Política de Privacidad
**Última actualización:** 14 de junio de 2026
## Responsable
**Nombre de la app:** Factura del Hogar
**Desarrollador:** Agent
**Contacto:** agent@devlab.lat
## Recopilación de datos
Factura del Hogar **no recopila, almacena ni transmite ningún dato personal** del usuario. Toda la información ingresada en la aplicación (servicios, facturas, configuración) se almacena exclusivamente en el dispositivo del usuario mediante una base de datos SQLite local.
## Permisos solicitados
- **POST_NOTIFICATIONS:** para mostrar recordatorios de vencimiento de facturas.
- **RECEIVE_BOOT_COMPLETED:** para reprogramar recordatorios después de reiniciar el dispositivo.
- **SCHEDULE_EXACT_ALARM:** para programar notificaciones en horarios exactos.
Ninguno de estos permisos se utiliza para acceder, recopilar o transmitir datos personales.
## Conexión a internet
La aplicación **no requiere conexión a internet** para funcionar. No realiza comunicaciones de red, no envía datos a servidores externos ni integra servicios de terceros que recopilen información.
## Servicios de terceros
Esta aplicación no integra SDKs de terceros (analíticas, publicidad, redes sociales, etc.).
## Cambios en esta política
Cualquier cambio en esta política de privacidad se reflejará en esta misma página.
---
*Factura del Hogar — Controla tus gastos del hogar de forma privada y segura.*

View File

@@ -3,7 +3,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<application <application
android:label="hogarcontrol" android:label="Factura del Hogar"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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