Agrega PRIVACY.md, actualiza AGENTS.md
68
AGENTS.md
@@ -1,48 +1,55 @@
|
|||||||
# HogarControl
|
# Factura del Hogar
|
||||||
|
|
||||||
Flutter app for tracking home bills and recurring expenses. All data stored locally via SQLite — no internet required, no accounts.
|
Flutter app for tracking home bills and recurring expenses. All data stored locally via SQLite — no internet, no accounts.
|
||||||
|
|
||||||
## Build & install
|
## 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
@@ -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.*
|
||||||
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 35 KiB |
@@ -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(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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!),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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()),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
76
lib/utils/currency_utils.dart
Normal 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: 'zł'),
|
||||||
|
];
|
||||||
|
|
||||||
|
static const _zeroDecimal = {'JPY', 'KRW', 'CLP', 'PYG', 'COP'};
|
||||||
|
|
||||||
|
static String getSymbol(String code) {
|
||||||
|
final idx = allCurrencies.indexWhere((c) => c.code == code);
|
||||||
|
return idx >= 0 ? allCurrencies[idx].symbol : r'$';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String getDisplayName(String code) {
|
||||||
|
final idx = allCurrencies.indexWhere((c) => c.code == code);
|
||||||
|
return idx >= 0 ? '${allCurrencies[idx].code} - ${allCurrencies[idx].name}' : code;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String formatPrice(double amount, String code) {
|
||||||
|
final symbol = getSymbol(code);
|
||||||
|
final fmt = _zeroDecimal.contains(code)
|
||||||
|
? NumberFormat('#,##0')
|
||||||
|
: NumberFormat('#,##0.00');
|
||||||
|
return '$symbol${fmt.format(amount)}';
|
||||||
|
}
|
||||||
|
}
|
||||||
29
lib/utils/transitions.dart
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||