diff --git a/lib/main.dart b/lib/main.dart index 237165a..210c91c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'package:prosapp_web_app/providers/score_provider.dart'; import 'package:prosapp_web_app/providers/services_provider.dart'; import 'package:prosapp_web_app/providers/settings_provider.dart'; import 'package:prosapp_web_app/providers/sidemenu_provider.dart'; +import 'package:prosapp_web_app/providers/theme_provider.dart'; import 'package:prosapp_web_app/router/router.dart'; import 'package:prosapp_web_app/services/local_storage.dart'; import 'package:prosapp_web_app/services/navigation_service.dart'; @@ -26,15 +27,10 @@ import 'package:provider/provider.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - await dotenv.load(fileName: ".env"); - await LocalStorage.configurePrefs(); - await initializeDateFormatting('es_ES', null); - Flurorouter.configureRoutes(); - runApp(const AppState()); } @@ -48,6 +44,7 @@ class AppState extends StatelessWidget { ChangeNotifierProvider(lazy: false, create: (_) => AuthProvider()), ChangeNotifierProvider(lazy: false, create: (_) => SideMenuProvider()), ChangeNotifierProvider(lazy: false, create: (_) => ProfessionalProvider()), + ChangeNotifierProvider(lazy: false, create: (_) => ThemeProvider()), ChangeNotifierProvider(create: (_) => SettingsProvider()), ChangeNotifierProvider(create: (_) => ProfileFormProvider()), ChangeNotifierProvider(create: (_) => ProfessionalFormProvider()), @@ -70,6 +67,8 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { + final themeProvider = Provider.of(context); + return MaterialApp( debugShowCheckedModeBanner: false, title: 'ProsApp', @@ -77,25 +76,46 @@ class MyApp extends StatelessWidget { onGenerateRoute: Flurorouter.router.generator, navigatorKey: NavigationService.navigatorKey, scaffoldMessengerKey: NotificationsService.messengerKey, + themeMode: themeProvider.mode, + theme: _buildLight(), + darkTheme: _buildDark(), builder: (_, child) { final authProvider = Provider.of(context); - - if (authProvider.authStatus == AuthStatus.checking) { - return const SplashLayout(); - } - - if (authProvider.authStatus == AuthStatus.authenticated) { - return DashboardLayout(child: child!); - } else { - return AuthLayout(child: child!); - } + if (authProvider.authStatus == AuthStatus.checking) return const SplashLayout(); + if (authProvider.authStatus == AuthStatus.authenticated) return DashboardLayout(child: child!); + return AuthLayout(child: child!); }, - theme: ThemeData.light().copyWith( - scrollbarTheme: const ScrollbarThemeData().copyWith( - thumbColor: WidgetStateProperty.all( - Colors.grey.withOpacity(0.5), - ), - ), + ); + } + + ThemeData _buildLight() { + return ThemeData.light().copyWith( + colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF42A4EF)), + scaffoldBackgroundColor: const Color(0xFFF4F6FA), + cardColor: Colors.white, + scrollbarTheme: const ScrollbarThemeData().copyWith( + thumbColor: WidgetStateProperty.all(Colors.grey.withOpacity(0.5)), + ), + ); + } + + ThemeData _buildDark() { + return ThemeData.dark().copyWith( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF42A4EF), + brightness: Brightness.dark, + ), + scaffoldBackgroundColor: const Color(0xFF0F172A), + cardColor: const Color(0xFF1E293B), + dialogBackgroundColor: const Color(0xFF1E293B), + appBarTheme: const AppBarTheme(backgroundColor: Color(0xFF1E293B)), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: const Color(0xFF1E293B), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + scrollbarTheme: const ScrollbarThemeData().copyWith( + thumbColor: WidgetStateProperty.all(Colors.white24), ), ); } diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart new file mode 100644 index 0000000..9f59c67 --- /dev/null +++ b/lib/providers/theme_provider.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:prosapp_web_app/services/local_storage.dart'; + +class ThemeProvider extends ChangeNotifier { + static const _key = 'dark_mode'; + + ThemeMode _mode = ThemeMode.light; + ThemeMode get mode => _mode; + bool get isDark => _mode == ThemeMode.dark; + + ThemeProvider() { + final saved = LocalStorage.prefs.getBool(_key) ?? false; + _mode = saved ? ThemeMode.dark : ThemeMode.light; + } + + void toggle() { + _mode = isDark ? ThemeMode.light : ThemeMode.dark; + LocalStorage.prefs.setBool(_key, isDark); + notifyListeners(); + } +} diff --git a/lib/ui/shared/navbar.dart b/lib/ui/shared/navbar.dart index e9389f5..e0d155f 100644 --- a/lib/ui/shared/navbar.dart +++ b/lib/ui/shared/navbar.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:prosapp_web_app/providers/auth_provider.dart'; import 'package:prosapp_web_app/providers/professional_provider.dart'; import 'package:prosapp_web_app/providers/score_provider.dart'; - +import 'package:prosapp_web_app/providers/services_provider.dart'; import 'package:prosapp_web_app/providers/sidemenu_provider.dart'; - +import 'package:prosapp_web_app/providers/theme_provider.dart'; import 'package:prosapp_web_app/ui/shared/widgets/navbar_avatar.dart'; import 'package:provider/provider.dart'; @@ -14,58 +14,221 @@ class Navbar extends StatelessWidget { @override Widget build(BuildContext context) { final size = MediaQuery.of(context).size; - final professionalProvider = Provider.of(context); + final themeProvider = Provider.of(context); final scoreProvider = Provider.of(context, listen: false); + final isDark = themeProvider.isDark; WidgetsBinding.instance.addPostFrameCallback((_) { scoreProvider.loadReputation(context.read().user!.id); }); - return Consumer(builder: (context, scoreProvider, child) { + return Consumer(builder: (context, scoreProvider, _) { final reputation = scoreProvider.reputation; return Container( width: double.infinity, - height: 50, - decoration: buildBoxDecoration(), + height: 56, + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + boxShadow: [ + BoxShadow( + color: isDark ? Colors.black38 : Colors.black12, + blurRadius: 6, + ), + ], + ), child: Row( children: [ - if (size.width <= 700) ...[ + if (size.width <= 700) IconButton( - icon: const Icon(Icons.menu_outlined), + icon: Icon(Icons.menu_outlined, + color: isDark ? Colors.white70 : Colors.black87), onPressed: () => SideMenuProvider.openMenu(), ), - ], - const SizedBox(width: 5), + const SizedBox(width: 8), const Spacer(), + + // Puntuación Row( children: [ - Icon(Icons.star, color: Colors.yellow[600], size: 15), - const SizedBox(width: 2), + Icon(Icons.star, color: Colors.amber[600], size: 16), + const SizedBox(width: 3), Text( professionalProvider.isProModeActive - ? reputation.averagePro.toStringAsFixed(2) - : reputation.average.toStringAsFixed(2), - style: const TextStyle(fontSize: 15), + ? reputation.averagePro.toStringAsFixed(1) + : reputation.average.toStringAsFixed(1), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white70 : Colors.black87, + ), ), ], ), - const SizedBox(width: 10), + + const SizedBox(width: 8), + + // Campana de notificaciones + _NotificationBell(isDark: isDark), + + const SizedBox(width: 4), + + // Avatar const NavbarAvatar(), - const SizedBox(width: 10), + const SizedBox(width: 12), ], ), ); }); } +} - BoxDecoration buildBoxDecoration() { - return const BoxDecoration(color: Colors.white, boxShadow: [ - BoxShadow( - color: Colors.black12, - blurRadius: 5, +class _NotificationBell extends StatelessWidget { + final bool isDark; + const _NotificationBell({required this.isDark}); + + @override + Widget build(BuildContext context) { + final professionalProvider = Provider.of(context); + + return Consumer(builder: (context, servicesProvider, _) { + // Cuenta servicios pendientes según el modo + final pending = servicesProvider.services + .where((s) => s.service.status.index == 0) // ServiceStatus.pending + .length; + + return Stack( + clipBehavior: Clip.none, + children: [ + IconButton( + tooltip: 'Notificaciones', + icon: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Icon( + pending > 0 ? Icons.notifications_active : Icons.notifications_outlined, + key: ValueKey(pending > 0), + color: pending > 0 + ? const Color(0xFF42A4EF) + : (isDark ? Colors.white54 : Colors.black54), + size: 22, + ), + ), + onPressed: () => _showNotificationsSheet(context, professionalProvider.isProModeActive, servicesProvider), + ), + if (pending > 0) + Positioned( + top: 6, + right: 6, + child: Container( + width: 16, + height: 16, + decoration: const BoxDecoration( + color: Color(0xFFEF4444), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + pending > 9 ? '9+' : '$pending', + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ], + ); + }); + } + + void _showNotificationsSheet(BuildContext context, bool isPro, ServicesProvider sp) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final pending = sp.services.where((s) => s.service.status.index == 0).toList(); + + showModalBottomSheet( + context: context, + backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - ]); + builder: (_) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, height: 4, + decoration: BoxDecoration(color: Colors.grey[400], borderRadius: BorderRadius.circular(2)), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + const Icon(Icons.notifications_outlined, size: 20), + const SizedBox(width: 8), + const Text('Notificaciones', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const Spacer(), + if (pending.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF42A4EF), + borderRadius: BorderRadius.circular(10), + ), + child: Text('${pending.length} nuevas', + style: const TextStyle(color: Colors.white, fontSize: 11)), + ), + ], + ), + const SizedBox(height: 12), + if (pending.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Column( + children: [ + Icon(Icons.notifications_none, size: 48, color: Colors.grey[400]), + const SizedBox(height: 8), + Text('Sin notificaciones pendientes', + style: TextStyle(color: Colors.grey[500])), + ], + ), + ), + ) + else + ...pending.take(5).map((s) => ListTile( + contentPadding: EdgeInsets.zero, + leading: Container( + width: 40, height: 40, + decoration: BoxDecoration( + color: const Color(0xFF42A4EF).withOpacity(0.12), + shape: BoxShape.circle, + ), + child: const Icon(Icons.room_service_outlined, + color: Color(0xFF42A4EF), size: 20), + ), + title: Text( + isPro ? 'Nueva solicitud de servicio' : 'Servicio pendiente', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + subtitle: Text( + s.service.address.isNotEmpty ? s.service.address : 'Sin dirección', + style: const TextStyle(fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: const Icon(Icons.circle, color: Color(0xFF42A4EF), size: 8), + )), + ], + ), + ); + }, + ); } } diff --git a/lib/ui/shared/sidebar.dart b/lib/ui/shared/sidebar.dart index a4a9a88..b0044b8 100644 --- a/lib/ui/shared/sidebar.dart +++ b/lib/ui/shared/sidebar.dart @@ -3,6 +3,7 @@ import 'package:prosapp_web_app/providers/auth_provider.dart'; import 'package:prosapp_web_app/providers/professional_provider.dart'; import 'package:prosapp_web_app/providers/services_provider.dart'; import 'package:prosapp_web_app/providers/sidemenu_provider.dart'; +import 'package:prosapp_web_app/providers/theme_provider.dart'; import 'package:prosapp_web_app/router/router.dart'; import 'package:prosapp_web_app/services/navigation_service.dart'; import 'package:prosapp_web_app/services/notifications_service.dart'; @@ -25,6 +26,7 @@ class Sidebar extends StatelessWidget { final sideMenuProvider = Provider.of(context); final authProvider = Provider.of(context, listen: false); final professionalProvider = Provider.of(context); + final themeProvider = Provider.of(context); return Container( width: 220, @@ -188,6 +190,79 @@ class Sidebar extends StatelessWidget { onPressed: () => authProvider.logout(), ), const SizedBox(height: 20), + + // ── Toggle día / noche ── + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + child: GestureDetector( + onTap: themeProvider.toggle, + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: themeProvider.isDark + ? const Color(0xFF1E293B) + : Colors.white.withOpacity(0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Colors.white.withOpacity(0.12), + ), + ), + child: Row( + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Icon( + themeProvider.isDark + ? Icons.light_mode_outlined + : Icons.dark_mode_outlined, + key: ValueKey(themeProvider.isDark), + color: themeProvider.isDark + ? Colors.amber + : const Color(0xFF8BAFD4), + size: 18, + ), + ), + const SizedBox(width: 10), + Text( + themeProvider.isDark ? 'Modo día' : 'Modo noche', + style: const TextStyle( + color: Color(0xFF8BAFD4), + fontSize: 13, + ), + ), + const Spacer(), + AnimatedContainer( + duration: const Duration(milliseconds: 250), + width: 36, + height: 20, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: themeProvider.isDark + ? const Color(0xFF42A4EF) + : Colors.white.withOpacity(0.2), + ), + child: AnimatedAlign( + duration: const Duration(milliseconds: 250), + alignment: themeProvider.isDark + ? Alignment.centerRight + : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.all(3), + width: 14, + height: 14, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + ), + ), + ], + ), + ), + ), + ), ], ), );