diff --git a/lib/core/services/user_api_service.dart b/lib/core/services/user_api_service.dart new file mode 100644 index 0000000..24ebdd7 --- /dev/null +++ b/lib/core/services/user_api_service.dart @@ -0,0 +1,148 @@ +import '../api/api_client.dart'; +import '../models/app_user.dart'; + +/// Service for user management via API (Admin only) +class UserApiService { + final ApiClient _api = ApiClient(); + + /// Get all users (Admin only) + Future> getAllUsers() async { + try { + _api.restoreToken(); + final response = await _api.get('/api/users'); + final data = response.data as Map; + + if (data['success'] == true) { + final usersData = data['users'] as List; + return usersData.map((u) => AppUser( + id: u['id'] as String, + username: u['username'] as String, + passwordHash: '', + isAdmin: u['isAdmin'] as bool? ?? false, + createdAt: DateTime.tryParse(u['createdAt'] ?? '') ?? DateTime.now(), + )).toList(); + } + return []; + } catch (e) { + throw Exception('Failed to fetch users: $e'); + } + } + + /// Create a new user (Admin only) + Future createUser({ + required String username, + required String password, + bool isAdmin = false, + }) async { + try { + _api.restoreToken(); + final response = await _api.post('/api/users', data: { + 'username': username, + 'password': password, + 'isAdmin': isAdmin, + }); + + final data = response.data as Map; + + if (data['success'] == true) { + final userData = data['user'] as Map; + return UserResult( + success: true, + user: AppUser( + id: userData['id'] as String, + username: userData['username'] as String, + passwordHash: '', + isAdmin: userData['isAdmin'] as bool? ?? false, + createdAt: DateTime.now(), + ), + ); + } + return UserResult( + success: false, + error: data['error'] as String? ?? 'Failed to create user', + ); + } catch (e) { + return UserResult(success: false, error: 'Network error: $e'); + } + } + + /// Update user password (Admin only) + Future updatePassword({ + required String userId, + required String newPassword, + }) async { + try { + _api.restoreToken(); + final response = await _api.put('/api/users/$userId/password', data: { + 'password': newPassword, + }); + + final data = response.data as Map; + + if (data['success'] == true) { + return UserResult(success: true); + } + return UserResult( + success: false, + error: data['error'] as String? ?? 'Failed to update password', + ); + } catch (e) { + return UserResult(success: false, error: 'Network error: $e'); + } + } + + /// Delete a user (Admin only) + Future deleteUser(String userId) async { + try { + _api.restoreToken(); + final response = await _api.delete('/api/users/$userId'); + + final data = response.data as Map; + + if (data['success'] == true) { + return UserResult(success: true); + } + return UserResult( + success: false, + error: data['error'] as String? ?? 'Failed to delete user', + ); + } catch (e) { + return UserResult(success: false, error: 'Network error: $e'); + } + } + + /// Toggle admin status (Admin only) + Future toggleAdmin(String userId, bool isAdmin) async { + try { + _api.restoreToken(); + final response = await _api.put('/api/users/$userId', data: { + 'isAdmin': isAdmin, + }); + + final data = response.data as Map; + + if (data['success'] == true) { + return UserResult(success: true); + } + return UserResult( + success: false, + error: data['error'] as String? ?? 'Failed to update user', + ); + } catch (e) { + return UserResult(success: false, error: 'Network error: $e'); + } + } +} + +/// Result of user operation +class UserResult { + final bool success; + final AppUser? user; + final String? error; + + UserResult({ + required this.success, + this.user, + this.error, + }); +} diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart new file mode 100644 index 0000000..3860942 --- /dev/null +++ b/lib/core/theme/app_colors.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +/// XtremFlow Premium Color Palette +/// +/// Modern dark theme with cyan/teal gradients +class AppColors { + AppColors._(); + + // ============ PRIMARY GRADIENT ============ + /// Cyan primary - main accent color + static const Color primary = Color(0xFF00D9FF); + + /// Teal secondary - gradient end + static const Color secondary = Color(0xFF00BFA5); + + /// Primary gradient for buttons and highlights + static const LinearGradient primaryGradient = LinearGradient( + colors: [primary, secondary], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + + // ============ SURFACE COLORS ============ + /// Deep dark background + static const Color background = Color(0xFF0A0E14); + + /// Card/elevated surface + static const Color surface = Color(0xFF141A22); + + /// Lighter surface variant (hover states, inputs) + static const Color surfaceVariant = Color(0xFF1E2630); + + /// Border/divider color + static const Color border = Color(0xFF2A3441); + + /// Overlay color (modals, dialogs) + static const Color overlay = Color(0xCC0A0E14); + + // ============ TEXT COLORS ============ + /// Primary text - white with high opacity + static const Color textPrimary = Color(0xFFF5F5F5); + + /// Secondary text - muted + static const Color textSecondary = Color(0xFFB0BEC5); + + /// Disabled/hint text + static const Color textDisabled = Color(0xFF607D8B); + + // ============ ACCENT COLORS ============ + /// Live indicator red + static const Color live = Color(0xFFFF6B6B); + + /// Success green + static const Color success = Color(0xFF4ADE80); + + /// Warning amber + static const Color warning = Color(0xFFFBBF24); + + /// Error red + static const Color error = Color(0xFFEF4444); + + /// Info blue + static const Color info = Color(0xFF3B82F6); + + // ============ CATEGORY COLORS ============ + /// Colors for category chips/tags + static const List categoryColors = [ + Color(0xFF6366F1), // Indigo + Color(0xFFEC4899), // Pink + Color(0xFF8B5CF6), // Purple + Color(0xFF14B8A6), // Teal + Color(0xFFF59E0B), // Amber + Color(0xFF10B981), // Emerald + ]; + + // ============ GLASSMORPHISM ============ + /// Glass effect background color + static const Color glassBackground = Color(0x1AFFFFFF); + + /// Glass border color + static const Color glassBorder = Color(0x33FFFFFF); + + // ============ DARK THEME COLOR SCHEME ============ + static ColorScheme get darkColorScheme => const ColorScheme.dark( + primary: primary, + secondary: secondary, + surface: surface, + error: error, + onPrimary: Color(0xFF000000), + onSecondary: Color(0xFF000000), + onSurface: textPrimary, + onError: Color(0xFFFFFFFF), + ); +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..607cfd2 --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,391 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'app_colors.dart'; + +/// XtremFlow Modern Theme +/// +/// Complete theme with typography, spacing, and component theming +class AppTheme { + AppTheme._(); + + // ============ SPACING SCALE ============ + static const double spacing4 = 4.0; + static const double spacing8 = 8.0; + static const double spacing12 = 12.0; + static const double spacing16 = 16.0; + static const double spacing24 = 24.0; + static const double spacing32 = 32.0; + static const double spacing48 = 48.0; + + // ============ BORDER RADIUS ============ + static const double radiusSm = 8.0; + static const double radiusMd = 12.0; + static const double radiusLg = 16.0; + static const double radiusXl = 24.0; + static const double radiusFull = 999.0; + + // ============ ANIMATION DURATIONS ============ + static const Duration durationFast = Duration(milliseconds: 150); + static const Duration durationNormal = Duration(milliseconds: 250); + static const Duration durationSlow = Duration(milliseconds: 400); + + // ============ ANIMATION CURVES ============ + static const Curve curveDefault = Curves.easeOutCubic; + static const Curve curveEmphasized = Curves.easeInOutCubic; + + // ============ DARK THEME ============ + static ThemeData get darkTheme { + final baseTheme = ThemeData.dark(useMaterial3: true); + + return baseTheme.copyWith( + colorScheme: AppColors.darkColorScheme, + scaffoldBackgroundColor: AppColors.background, + + // AppBar + appBarTheme: AppBarTheme( + backgroundColor: AppColors.surface, + surfaceTintColor: Colors.transparent, + elevation: 0, + centerTitle: false, + titleTextStyle: GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + iconTheme: const IconThemeData(color: AppColors.textPrimary), + ), + + // Navigation Bar (bottom) + navigationBarTheme: NavigationBarThemeData( + backgroundColor: AppColors.surface, + surfaceTintColor: Colors.transparent, + indicatorColor: AppColors.primary.withOpacity(0.2), + labelTextStyle: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ); + } + return GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ); + }), + iconTheme: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return const IconThemeData(color: AppColors.primary); + } + return const IconThemeData(color: AppColors.textSecondary); + }), + ), + + // Navigation Rail (desktop sidebar) + navigationRailTheme: NavigationRailThemeData( + backgroundColor: AppColors.surface, + indicatorColor: AppColors.primary.withOpacity(0.2), + selectedIconTheme: const IconThemeData(color: AppColors.primary), + unselectedIconTheme: const IconThemeData(color: AppColors.textSecondary), + selectedLabelTextStyle: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + unselectedLabelTextStyle: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + + // Cards + cardTheme: CardThemeData( + color: AppColors.surface, + surfaceTintColor: Colors.transparent, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusMd), + side: const BorderSide(color: AppColors.border, width: 1), + ), + margin: EdgeInsets.zero, + ), + + // Inputs + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceVariant, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusSm), + borderSide: const BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusSm), + borderSide: const BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusSm), + borderSide: const BorderSide(color: AppColors.primary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusSm), + borderSide: const BorderSide(color: AppColors.error), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: spacing16, + vertical: spacing12, + ), + hintStyle: GoogleFonts.inter( + color: AppColors.textDisabled, + fontSize: 14, + ), + labelStyle: GoogleFonts.inter( + color: AppColors.textSecondary, + fontSize: 14, + ), + ), + + // Buttons + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric( + horizontal: spacing24, + vertical: spacing12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusSm), + ), + textStyle: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primary), + padding: const EdgeInsets.symmetric( + horizontal: spacing24, + vertical: spacing12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusSm), + ), + textStyle: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: AppColors.primary, + textStyle: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + + // Chips + chipTheme: ChipThemeData( + backgroundColor: AppColors.surfaceVariant, + selectedColor: AppColors.primary.withOpacity(0.2), + labelStyle: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusFull), + ), + padding: const EdgeInsets.symmetric(horizontal: spacing8), + ), + + // Dialogs + dialogTheme: DialogThemeData( + backgroundColor: AppColors.surface, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusLg), + ), + titleTextStyle: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + contentTextStyle: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textSecondary, + ), + ), + + // Snackbars + snackBarTheme: SnackBarThemeData( + backgroundColor: AppColors.surfaceVariant, + contentTextStyle: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textPrimary, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusSm), + ), + behavior: SnackBarBehavior.floating, + ), + + // Tabs + tabBarTheme: TabBarThemeData( + labelColor: AppColors.primary, + unselectedLabelColor: AppColors.textSecondary, + labelStyle: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + unselectedLabelStyle: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + indicatorColor: AppColors.primary, + indicatorSize: TabBarIndicatorSize.tab, + dividerColor: AppColors.border, + ), + + // Dividers + dividerTheme: const DividerThemeData( + color: AppColors.border, + thickness: 1, + space: 1, + ), + + // List tiles + listTileTheme: ListTileThemeData( + tileColor: Colors.transparent, + contentPadding: const EdgeInsets.symmetric( + horizontal: spacing16, + vertical: spacing8, + ), + titleTextStyle: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + subtitleTextStyle: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusSm), + ), + ), + + // Progress indicators + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.primary, + linearTrackColor: AppColors.surfaceVariant, + circularTrackColor: AppColors.surfaceVariant, + ), + + // Switch + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return AppColors.primary; + } + return AppColors.textSecondary; + }), + trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return AppColors.primary.withOpacity(0.3); + } + return AppColors.surfaceVariant; + }), + ), + + // Text theme + textTheme: TextTheme( + displayLarge: GoogleFonts.inter( + fontSize: 57, + fontWeight: FontWeight.w400, + color: AppColors.textPrimary, + ), + displayMedium: GoogleFonts.inter( + fontSize: 45, + fontWeight: FontWeight.w400, + color: AppColors.textPrimary, + ), + displaySmall: GoogleFonts.inter( + fontSize: 36, + fontWeight: FontWeight.w400, + color: AppColors.textPrimary, + ), + headlineLarge: GoogleFonts.inter( + fontSize: 32, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + headlineMedium: GoogleFonts.inter( + fontSize: 28, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + headlineSmall: GoogleFonts.inter( + fontSize: 24, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + titleLarge: GoogleFonts.inter( + fontSize: 22, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + titleMedium: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + titleSmall: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + bodyLarge: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w400, + color: AppColors.textPrimary, + ), + bodyMedium: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.textPrimary, + ), + bodySmall: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + ), + labelLarge: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + labelMedium: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + labelSmall: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ); + } +} diff --git a/lib/core/utils/responsive_layout.dart b/lib/core/utils/responsive_layout.dart new file mode 100644 index 0000000..b9654da --- /dev/null +++ b/lib/core/utils/responsive_layout.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; + +/// Responsive breakpoints and layout utilities +class ResponsiveLayout { + ResponsiveLayout._(); + + // ============ BREAKPOINTS ============ + static const double mobileBreakpoint = 600; + static const double tabletBreakpoint = 900; + static const double desktopBreakpoint = 1200; + + /// Check if current screen is mobile size + static bool isMobile(BuildContext context) { + return MediaQuery.of(context).size.width < mobileBreakpoint; + } + + /// Check if current screen is tablet size + static bool isTablet(BuildContext context) { + final width = MediaQuery.of(context).size.width; + return width >= mobileBreakpoint && width < desktopBreakpoint; + } + + /// Check if current screen is desktop size + static bool isDesktop(BuildContext context) { + return MediaQuery.of(context).size.width >= desktopBreakpoint; + } + + /// Get current device type + static DeviceType getDeviceType(BuildContext context) { + final width = MediaQuery.of(context).size.width; + if (width < mobileBreakpoint) return DeviceType.mobile; + if (width < desktopBreakpoint) return DeviceType.tablet; + return DeviceType.desktop; + } + + /// Get responsive value based on screen size + static T value( + BuildContext context, { + required T mobile, + T? tablet, + required T desktop, + }) { + final type = getDeviceType(context); + switch (type) { + case DeviceType.mobile: + return mobile; + case DeviceType.tablet: + return tablet ?? desktop; + case DeviceType.desktop: + return desktop; + } + } + + /// Get grid columns based on screen size + static int gridColumns(BuildContext context) { + return value(context, mobile: 2, tablet: 3, desktop: 4); + } + + /// Get content max width + static double maxContentWidth(BuildContext context) { + return value(context, mobile: double.infinity, tablet: 900, desktop: 1200); + } +} + +/// Device types for responsive layouts +enum DeviceType { mobile, tablet, desktop } + +/// Widget that adapts layout based on screen size +class AdaptiveLayout extends StatelessWidget { + final Widget mobile; + final Widget? tablet; + final Widget desktop; + + const AdaptiveLayout({ + super.key, + required this.mobile, + this.tablet, + required this.desktop, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < ResponsiveLayout.mobileBreakpoint) { + return mobile; + } + if (constraints.maxWidth < ResponsiveLayout.desktopBreakpoint) { + return tablet ?? desktop; + } + return desktop; + }, + ); + } +} + +/// Wrapper that constrains content width on large screens +class ResponsiveContainer extends StatelessWidget { + final Widget child; + final EdgeInsetsGeometry padding; + + const ResponsiveContainer({ + super.key, + required this.child, + this.padding = const EdgeInsets.symmetric(horizontal: 16), + }); + + @override + Widget build(BuildContext context) { + return Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: ResponsiveLayout.maxContentWidth(context), + ), + child: Padding( + padding: padding, + child: child, + ), + ), + ); + } +} diff --git a/lib/core/widgets/components/ui_components.dart b/lib/core/widgets/components/ui_components.dart new file mode 100644 index 0000000..8350760 --- /dev/null +++ b/lib/core/widgets/components/ui_components.dart @@ -0,0 +1,308 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import '../../theme/app_colors.dart'; +import '../../theme/app_theme.dart'; + +/// Glassmorphism card widget with blur effect +class GlassCard extends StatelessWidget { + final Widget child; + final EdgeInsetsGeometry padding; + final double borderRadius; + final VoidCallback? onTap; + final bool showBorder; + + const GlassCard({ + super.key, + required this.child, + this.padding = const EdgeInsets.all(16), + this.borderRadius = 12, + this.onTap, + this.showBorder = true, + }); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Material( + color: AppColors.glassBackground, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(borderRadius), + child: Container( + padding: padding, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(borderRadius), + border: showBorder + ? Border.all(color: AppColors.glassBorder, width: 1) + : null, + ), + child: child, + ), + ), + ), + ), + ); + } +} + +/// Animated gradient button +class GradientButton extends StatefulWidget { + final String label; + final VoidCallback? onPressed; + final IconData? icon; + final bool isLoading; + final bool isExpanded; + + const GradientButton({ + super.key, + required this.label, + this.onPressed, + this.icon, + this.isLoading = false, + this.isExpanded = false, + }); + + @override + State createState() => _GradientButtonState(); +} + +class _GradientButtonState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: AppTheme.durationFast, + vsync: this, + ); + _scaleAnimation = Tween(begin: 1.0, end: 0.95).animate( + CurvedAnimation(parent: _controller, curve: AppTheme.curveDefault), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final button = AnimatedBuilder( + animation: _scaleAnimation, + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: child, + ); + }, + child: Container( + decoration: BoxDecoration( + gradient: AppColors.primaryGradient, + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.isLoading ? null : widget.onPressed, + onTapDown: (_) => _controller.forward(), + onTapUp: (_) => _controller.reverse(), + onTapCancel: () => _controller.reverse(), + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 14, + ), + child: widget.isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.black, + ), + ) + : Row( + mainAxisSize: + widget.isExpanded ? MainAxisSize.max : MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.icon != null) ...[ + Icon(widget.icon, color: Colors.black, size: 20), + const SizedBox(width: 8), + ], + Text( + widget.label, + style: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ], + ), + ), + ), + ), + ), + ); + + return widget.isExpanded + ? SizedBox(width: double.infinity, child: button) + : button; + } +} + +/// Shimmer loading effect widget +class ShimmerLoading extends StatefulWidget { + final double width; + final double height; + final double borderRadius; + + const ShimmerLoading({ + super.key, + this.width = double.infinity, + required this.height, + this.borderRadius = 8, + }); + + @override + State createState() => _ShimmerLoadingState(); +} + +class _ShimmerLoadingState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + )..repeat(); + _animation = Tween(begin: -2, end: 2).animate(_controller); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(widget.borderRadius), + gradient: LinearGradient( + begin: Alignment(_animation.value - 1, 0), + end: Alignment(_animation.value + 1, 0), + colors: const [ + AppColors.surfaceVariant, + AppColors.surface, + AppColors.surfaceVariant, + ], + ), + ), + ); + }, + ); + } +} + +/// Live indicator badge +class LiveBadge extends StatelessWidget { + const LiveBadge({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.live, + borderRadius: BorderRadius.circular(4), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.circle, size: 6, color: Colors.white), + SizedBox(width: 4), + Text( + 'LIVE', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +/// Category chip with color +class CategoryChip extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback? onTap; + + const CategoryChip({ + super.key, + required this.label, + this.isSelected = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: isSelected + ? AppColors.primary.withOpacity(0.2) + : AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(AppTheme.radiusFull), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppTheme.radiusFull), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppTheme.radiusFull), + border: Border.all( + color: isSelected ? AppColors.primary : AppColors.border, + ), + ), + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected ? AppColors.primary : AppColors.textSecondary, + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/admin/screens/admin_panel.dart b/lib/features/admin/screens/admin_panel.dart index 1648d63..b207927 100644 --- a/lib/features/admin/screens/admin_panel.dart +++ b/lib/features/admin/screens/admin_panel.dart @@ -1,11 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../../core/services/playlist_api_service.dart'; +import '../../../core/services/user_api_service.dart'; import '../../../core/models/playlist_config.dart'; +import '../../../core/models/app_user.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../core/widgets/components/ui_components.dart'; import '../../iptv/screens/playlist_selection_screen.dart'; +/// Users provider for admin panel +final usersProvider = FutureProvider>((ref) async { + final service = UserApiService(); + return service.getAllUsers(); +}); + class AdminPanel extends ConsumerStatefulWidget { const AdminPanel({super.key}); @@ -13,21 +23,59 @@ class AdminPanel extends ConsumerStatefulWidget { ConsumerState createState() => _AdminPanelState(); } -class _AdminPanelState extends ConsumerState { +class _AdminPanelState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text( - 'Admin Panel', - style: GoogleFonts.roboto(fontWeight: FontWeight.w600), + title: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.admin_panel_settings, size: 20), + ), + const SizedBox(width: 12), + const Text('Administration'), + ], ), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => context.go('/playlists'), ), + bottom: TabBar( + controller: _tabController, + tabs: const [ + Tab(icon: Icon(Icons.playlist_play), text: 'Playlists'), + Tab(icon: Icon(Icons.people), text: 'Utilisateurs'), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: const [ + _PlaylistsTab(), + _UsersTab(), + ], ), - body: const _PlaylistsTab(), ); } } @@ -44,10 +92,10 @@ class _PlaylistsTab extends ConsumerWidget { children: [ Padding( padding: const EdgeInsets.all(16.0), - child: ElevatedButton.icon( + child: GradientButton( + label: 'Ajouter une playlist', + icon: Icons.add, onPressed: () => _showPlaylistDialog(context, ref), - icon: const Icon(Icons.add), - label: const Text('Add Playlist'), ), ), Expanded( @@ -57,13 +105,13 @@ class _PlaylistsTab extends ConsumerWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.error_outline, size: 48, color: Colors.red.shade400), + Icon(Icons.error_outline, size: 48, color: AppColors.error), const SizedBox(height: 16), - Text('Error loading playlists: $error'), + Text('Erreur: $error'), const SizedBox(height: 8), - ElevatedButton( + OutlinedButton( onPressed: () => ref.refresh(playlistsProvider), - child: const Text('Retry'), + child: const Text('Réessayer'), ), ], ), @@ -74,16 +122,11 @@ class _PlaylistsTab extends ConsumerWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.playlist_remove, size: 64, color: Colors.grey.shade400), + Icon(Icons.playlist_remove, size: 64, color: AppColors.textSecondary), const SizedBox(height: 16), Text( - 'No playlists yet', - style: GoogleFonts.roboto(fontSize: 18, color: Colors.grey.shade600), - ), - const SizedBox(height: 8), - Text( - 'Click "Add Playlist" to create one', - style: GoogleFonts.roboto(fontSize: 14, color: Colors.grey.shade500), + 'Aucune playlist', + style: TextStyle(fontSize: 18, color: AppColors.textSecondary), ), ], ), @@ -91,29 +134,33 @@ class _PlaylistsTab extends ConsumerWidget { } return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: playlists.length, itemBuilder: (context, index) { final playlist = playlists[index]; return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + margin: const EdgeInsets.only(bottom: 8), child: ListTile( - leading: const CircleAvatar( - child: Icon(Icons.playlist_play), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + gradient: AppColors.primaryGradient, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.playlist_play, color: Colors.black, size: 20), ), title: Text(playlist.name), - subtitle: Text( - playlist.dns, - style: GoogleFonts.roboto(fontSize: 11), - ), + subtitle: Text(playlist.dns, style: const TextStyle(fontSize: 11)), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: const Icon(Icons.edit), + icon: const Icon(Icons.edit_outlined), onPressed: () => _showPlaylistDialog(context, ref, playlist), ), IconButton( - icon: const Icon(Icons.delete), + icon: Icon(Icons.delete_outline, color: AppColors.error), onPressed: () => _deletePlaylist(context, ref, playlist), ), ], @@ -141,103 +188,40 @@ class _PlaylistsTab extends ConsumerWidget { context: context, builder: (dialogContext) => StatefulBuilder( builder: (context, setState) => AlertDialog( - title: Text(playlist == null ? 'Add Playlist' : 'Edit Playlist'), + title: Text(playlist == null ? 'Ajouter une playlist' : 'Modifier la playlist'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ - TextField( - controller: nameController, - decoration: const InputDecoration(labelText: 'Playlist Name'), - ), - const SizedBox(height: 8), - TextField( - controller: dnsController, - decoration: const InputDecoration( - labelText: 'Server URL', - hintText: 'http://server.com:8080', - ), - ), - const SizedBox(height: 8), - TextField( - controller: usernameController, - decoration: const InputDecoration(labelText: 'Username'), - ), - const SizedBox(height: 8), - TextField( - controller: passwordController, - decoration: const InputDecoration(labelText: 'Password'), - obscureText: true, - ), + TextField(controller: nameController, decoration: const InputDecoration(labelText: 'Nom')), + const SizedBox(height: 12), + TextField(controller: dnsController, decoration: const InputDecoration(labelText: 'URL du serveur', hintText: 'http://server.com:8080')), + const SizedBox(height: 12), + TextField(controller: usernameController, decoration: const InputDecoration(labelText: 'Utilisateur')), + const SizedBox(height: 12), + TextField(controller: passwordController, decoration: const InputDecoration(labelText: 'Mot de passe'), obscureText: true), ], ), ), actions: [ - TextButton( - onPressed: isLoading ? null : () => Navigator.pop(dialogContext), - child: const Text('Cancel'), - ), + TextButton(onPressed: isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler')), FilledButton( - onPressed: isLoading - ? null - : () async { - if (nameController.text.trim().isEmpty || - dnsController.text.trim().isEmpty || - usernameController.text.trim().isEmpty || - passwordController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('All fields required')), - ); - return; - } - - setState(() => isLoading = true); - - final dns = dnsController.text.trim().replaceAll(RegExp(r'/$'), ''); - - PlaylistConfig? result; - if (playlist == null) { - // Create new playlist - result = await service.createPlaylist( - name: nameController.text.trim(), - dns: dns, - username: usernameController.text.trim(), - password: passwordController.text, - ); - } else { - // Update existing playlist - result = await service.updatePlaylist( - id: playlist.id, - name: nameController.text.trim(), - dns: dns, - username: usernameController.text.trim(), - password: passwordController.text, - ); - } - - if (dialogContext.mounted) { - Navigator.pop(dialogContext); - } - - // Refresh playlists list - ref.invalidate(playlistsProvider); - - if (result == null && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Failed to save playlist'), - backgroundColor: Colors.red, - ), - ); - } - }, - child: isLoading - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ) - : const Text('Save'), + onPressed: isLoading ? null : () async { + if (nameController.text.isEmpty || dnsController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tous les champs sont requis'))); + return; + } + setState(() => isLoading = true); + final dns = dnsController.text.trim().replaceAll(RegExp(r'/$'), ''); + if (playlist == null) { + await service.createPlaylist(name: nameController.text.trim(), dns: dns, username: usernameController.text.trim(), password: passwordController.text); + } else { + await service.updatePlaylist(id: playlist.id, name: nameController.text.trim(), dns: dns, username: usernameController.text.trim(), password: passwordController.text); + } + if (dialogContext.mounted) Navigator.pop(dialogContext); + ref.invalidate(playlistsProvider); + }, + child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Sauvegarder'), ), ], ), @@ -246,38 +230,230 @@ class _PlaylistsTab extends ConsumerWidget { } void _deletePlaylist(BuildContext context, WidgetRef ref, PlaylistConfig playlist) { - final service = PlaylistApiService(); - showDialog( context: context, builder: (dialogContext) => AlertDialog( - title: const Text('Delete Playlist'), - content: Text('Delete playlist "${playlist.name}"?'), + title: const Text('Supprimer la playlist'), + content: Text('Supprimer "${playlist.name}" ?'), actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: const Text('Cancel'), - ), + TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Annuler')), FilledButton( onPressed: () async { Navigator.pop(dialogContext); - - final success = await service.deletePlaylist(playlist.id); - - // Refresh playlists list + await PlaylistApiService().deletePlaylist(playlist.id); ref.invalidate(playlistsProvider); - - if (!success && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Failed to delete playlist'), - backgroundColor: Colors.red, - ), - ); - } }, - style: FilledButton.styleFrom(backgroundColor: Colors.red), - child: const Text('Delete'), + style: FilledButton.styleFrom(backgroundColor: AppColors.error), + child: const Text('Supprimer'), + ), + ], + ), + ); + } +} + +// ========== USERS TAB ========== +class _UsersTab extends ConsumerWidget { + const _UsersTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final usersAsync = ref.watch(usersProvider); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: GradientButton( + label: 'Créer un utilisateur', + icon: Icons.person_add, + onPressed: () => _showCreateUserDialog(context, ref), + ), + ), + Expanded( + child: usersAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 48, color: AppColors.error), + const SizedBox(height: 16), + Text('Erreur: $error'), + OutlinedButton( + onPressed: () => ref.refresh(usersProvider), + child: const Text('Réessayer'), + ), + ], + ), + ), + data: (users) { + if (users.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.people_outline, size: 64, color: AppColors.textSecondary), + const SizedBox(height: 16), + Text('Aucun utilisateur', style: TextStyle(fontSize: 18, color: AppColors.textSecondary)), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: users.length, + itemBuilder: (context, index) { + final user = users[index]; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar( + backgroundColor: user.isAdmin ? AppColors.primary.withOpacity(0.2) : AppColors.surfaceVariant, + child: Icon(user.isAdmin ? Icons.admin_panel_settings : Icons.person, color: user.isAdmin ? AppColors.primary : AppColors.textSecondary), + ), + title: Row( + children: [ + Text(user.username), + if (user.isAdmin) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration(color: AppColors.primary.withOpacity(0.2), borderRadius: BorderRadius.circular(12)), + child: Text('Admin', style: TextStyle(fontSize: 10, color: AppColors.primary, fontWeight: FontWeight.w600)), + ), + ], + ], + ), + subtitle: Text('ID: ${user.id}', style: const TextStyle(fontSize: 11)), + trailing: PopupMenuButton( + onSelected: (value) { + switch (value) { + case 'password': _showChangePasswordDialog(context, ref, user); break; + case 'delete': _deleteUser(context, ref, user); break; + } + }, + itemBuilder: (context) => [ + const PopupMenuItem(value: 'password', child: ListTile(leading: Icon(Icons.lock_reset), title: Text('Changer mot de passe'), dense: true)), + PopupMenuItem(value: 'delete', child: ListTile(leading: Icon(Icons.delete, color: AppColors.error), title: Text('Supprimer', style: TextStyle(color: AppColors.error)), dense: true)), + ], + ), + ), + ); + }, + ); + }, + ), + ), + ], + ); + } + + void _showCreateUserDialog(BuildContext context, WidgetRef ref) { + final usernameController = TextEditingController(); + final passwordController = TextEditingController(); + bool isAdmin = false; + bool isLoading = false; + + showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: const Text('Créer un utilisateur'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: usernameController, decoration: const InputDecoration(labelText: 'Nom d\'utilisateur', prefixIcon: Icon(Icons.person_outline))), + const SizedBox(height: 12), + TextField(controller: passwordController, decoration: const InputDecoration(labelText: 'Mot de passe', prefixIcon: Icon(Icons.lock_outline)), obscureText: true), + const SizedBox(height: 16), + SwitchListTile( + title: const Text('Administrateur'), + subtitle: const Text('Accès au panneau admin'), + value: isAdmin, + onChanged: (v) => setState(() => isAdmin = v), + ), + ], + ), + actions: [ + TextButton(onPressed: isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler')), + FilledButton( + onPressed: isLoading ? null : () async { + if (usernameController.text.isEmpty || passwordController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tous les champs sont requis'))); + return; + } + setState(() => isLoading = true); + final result = await UserApiService().createUser(username: usernameController.text.trim(), password: passwordController.text, isAdmin: isAdmin); + if (dialogContext.mounted) Navigator.pop(dialogContext); + ref.invalidate(usersProvider); + if (!result.success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result.error ?? 'Erreur'), backgroundColor: AppColors.error)); + } + }, + child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Créer'), + ), + ], + ), + ), + ); + } + + void _showChangePasswordDialog(BuildContext context, WidgetRef ref, AppUser user) { + final passwordController = TextEditingController(); + bool isLoading = false; + + showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: Text('Changer le mot de passe de ${user.username}'), + content: TextField(controller: passwordController, decoration: const InputDecoration(labelText: 'Nouveau mot de passe', prefixIcon: Icon(Icons.lock_outline)), obscureText: true), + actions: [ + TextButton(onPressed: isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler')), + FilledButton( + onPressed: isLoading ? null : () async { + if (passwordController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Mot de passe requis'))); + return; + } + setState(() => isLoading = true); + final result = await UserApiService().updatePassword(userId: user.id, newPassword: passwordController.text); + if (dialogContext.mounted) Navigator.pop(dialogContext); + if (result.success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Mot de passe modifié'), backgroundColor: AppColors.success)); + } else if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result.error ?? 'Erreur'), backgroundColor: AppColors.error)); + } + }, + child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Changer'), + ), + ], + ), + ), + ); + } + + void _deleteUser(BuildContext context, WidgetRef ref, AppUser user) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Supprimer l\'utilisateur'), + content: Text('Supprimer "${user.username}" ? Cette action est irréversible.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Annuler')), + FilledButton( + onPressed: () async { + Navigator.pop(dialogContext); + final result = await UserApiService().deleteUser(user.id); + ref.invalidate(usersProvider); + if (!result.success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result.error ?? 'Erreur'), backgroundColor: AppColors.error)); + } + }, + style: FilledButton.styleFrom(backgroundColor: AppColors.error), + child: const Text('Supprimer'), ), ], ), diff --git a/lib/features/auth/screens/login_screen.dart b/lib/features/auth/screens/login_screen.dart index 4d82f19..17f8eb0 100644 --- a/lib/features/auth/screens/login_screen.dart +++ b/lib/features/auth/screens/login_screen.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../providers/auth_provider.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../core/widgets/components/ui_components.dart'; class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); @@ -44,156 +46,228 @@ class _LoginScreenState extends ConsumerState { return Scaffold( body: Container( - decoration: BoxDecoration( + decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - Colors.blue.shade900, - Colors.black, + Color(0xFF0F1923), // Deep dark blue + AppColors.background, + Color(0xFF0A1628), // Navy accent ], + stops: [0.0, 0.5, 1.0], ), ), - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Card( - elevation: 8, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + child: Stack( + children: [ + // Glow effect top + Positioned( + top: -100, + left: -100, child: Container( - constraints: const BoxConstraints(maxWidth: 400), - padding: const EdgeInsets.all(32.0), - child: Form( - key: _formKey, + width: 300, + height: 300, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.primary.withOpacity(0.1), + Colors.transparent, + ], + ), + ), + ), + ), + // Glow effect bottom + Positioned( + bottom: -150, + right: -150, + child: Container( + width: 400, + height: 400, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.secondary.withOpacity(0.08), + Colors.transparent, + ], + ), + ), + ), + ), + // Main content + Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Container( + constraints: const BoxConstraints(maxWidth: 400), child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Icon( - Icons.live_tv_rounded, - size: 64, - color: Colors.blue.shade700, + // Logo and title + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + gradient: AppColors.primaryGradient, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.4), + blurRadius: 30, + offset: const Offset(0, 10), + ), + ], + ), + child: const Icon( + Icons.live_tv, + color: Colors.black, + size: 40, + ), ), - const SizedBox(height: 16), - Text( - 'XtremFlow IPTV', - style: GoogleFonts.roboto( - fontSize: 28, - fontWeight: FontWeight.bold, + const SizedBox(height: 24), + ShaderMask( + shaderCallback: (bounds) => + AppColors.primaryGradient.createShader(bounds), + child: const Text( + 'XtremFlow', + style: TextStyle( + fontSize: 36, + fontWeight: FontWeight.w800, + color: Colors.white, + ), ), - textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( - 'Admin Access', - style: GoogleFonts.roboto( + 'Streaming IPTV Premium', + style: TextStyle( fontSize: 14, - color: Colors.grey.shade600, + color: AppColors.textSecondary, + letterSpacing: 1, ), - textAlign: TextAlign.center, ), - const SizedBox(height: 32), - TextFormField( - controller: _usernameController, - decoration: InputDecoration( - labelText: 'Username', - prefixIcon: const Icon(Icons.person), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Username required'; - } - return null; - }, - autofocus: true, - textInputAction: TextInputAction.next, - ), - const SizedBox(height: 16), - TextFormField( - controller: _passwordController, - obscureText: _obscurePassword, - decoration: InputDecoration( - labelText: 'Password', - prefixIcon: const Icon(Icons.lock), - suffixIcon: IconButton( - icon: Icon( - _obscurePassword - ? Icons.visibility - : Icons.visibility_off, - ), - onPressed: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - }, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Password required'; - } - return null; - }, - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => _handleLogin(), - ), - if (authState.errorMessage != null) ...[ - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.red.shade50, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.red.shade200), - ), - child: Row( + const SizedBox(height: 48), + // Login form + GlassCard( + padding: const EdgeInsets.all(24), + borderRadius: AppTheme.radiusLg, + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Icon(Icons.error_outline, color: Colors.red.shade700), - const SizedBox(width: 8), - Expanded( - child: Text( - authState.errorMessage!, - style: TextStyle(color: Colors.red.shade700), + Text( + 'Connexion', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, ), ), + const SizedBox(height: 24), + TextFormField( + controller: _usernameController, + decoration: InputDecoration( + labelText: 'Nom d\'utilisateur', + prefixIcon: Icon( + Icons.person_outline, + color: AppColors.textSecondary, + ), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Nom d\'utilisateur requis'; + } + return null; + }, + autofocus: true, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + decoration: InputDecoration( + labelText: 'Mot de passe', + prefixIcon: Icon( + Icons.lock_outline, + color: AppColors.textSecondary, + ), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.textSecondary, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Mot de passe requis'; + } + return null; + }, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _handleLogin(), + ), + if (authState.errorMessage != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.error.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.error.withOpacity(0.3), + ), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: AppColors.error, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + authState.errorMessage!, + style: TextStyle( + color: AppColors.error, + fontSize: 13, + ), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 24), + GradientButton( + label: 'Se connecter', + icon: Icons.login, + isLoading: authState.isLoading, + isExpanded: true, + onPressed: _handleLogin, + ), ], ), ), - ], - const SizedBox(height: 24), - ElevatedButton( - onPressed: authState.isLoading ? null : _handleLogin, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - child: authState.isLoading - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text( - 'Login', - style: TextStyle(fontSize: 16), - ), ), - const SizedBox(height: 16), + const SizedBox(height: 24), Text( - 'Default credentials: admin / admin', - style: GoogleFonts.roboto( + 'Identifiants par défaut: admin / admin', + style: TextStyle( fontSize: 12, - color: Colors.grey.shade500, + color: AppColors.textDisabled, fontStyle: FontStyle.italic, ), textAlign: TextAlign.center, @@ -203,7 +277,7 @@ class _LoginScreenState extends ConsumerState { ), ), ), - ), + ], ), ), ); diff --git a/lib/features/iptv/screens/dashboard_screen.dart b/lib/features/iptv/screens/dashboard_screen.dart index 745414c..e790ce9 100644 --- a/lib/features/iptv/screens/dashboard_screen.dart +++ b/lib/features/iptv/screens/dashboard_screen.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../../core/models/playlist_config.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../core/utils/responsive_layout.dart'; import '../widgets/live_tv_tab.dart'; import '../widgets/movies_tab.dart'; import '../widgets/series_tab.dart'; import '../widgets/settings_tab.dart'; +/// Main dashboard with responsive navigation (Rail for desktop, Bar for mobile) class DashboardScreen extends ConsumerStatefulWidget { final PlaylistConfig playlist; @@ -24,6 +27,52 @@ class _DashboardScreenState extends ConsumerState { late final List _tabs; + static const List _destinations = [ + NavigationDestination( + icon: Icon(Icons.live_tv_outlined), + selectedIcon: Icon(Icons.live_tv), + label: 'TV Live', + ), + NavigationDestination( + icon: Icon(Icons.movie_outlined), + selectedIcon: Icon(Icons.movie), + label: 'Films', + ), + NavigationDestination( + icon: Icon(Icons.tv_outlined), + selectedIcon: Icon(Icons.tv), + label: 'Séries', + ), + NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Paramètres', + ), + ]; + + static const List _railDestinations = [ + NavigationRailDestination( + icon: Icon(Icons.live_tv_outlined), + selectedIcon: Icon(Icons.live_tv), + label: Text('TV Live'), + ), + NavigationRailDestination( + icon: Icon(Icons.movie_outlined), + selectedIcon: Icon(Icons.movie), + label: Text('Films'), + ), + NavigationRailDestination( + icon: Icon(Icons.tv_outlined), + selectedIcon: Icon(Icons.tv), + label: Text('Séries'), + ), + NavigationRailDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: Text('Paramètres'), + ), + ]; + @override void initState() { super.initState(); @@ -37,41 +86,117 @@ class _DashboardScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final isDesktop = ResponsiveLayout.isDesktop(context); + return Scaffold( - appBar: AppBar( - title: Text( - widget.playlist.name, - style: GoogleFonts.roboto(fontWeight: FontWeight.w600), - ), - centerTitle: false, - ), - body: _tabs[_selectedIndex], - bottomNavigationBar: NavigationBar( - selectedIndex: _selectedIndex, - onDestinationSelected: (index) { - setState(() { - _selectedIndex = index; - }); - }, - destinations: const [ - NavigationDestination( - icon: Icon(Icons.live_tv), - label: 'Live TV', + appBar: _buildAppBar(), + body: isDesktop + ? _buildDesktopLayout() + : _buildMobileLayout(), + bottomNavigationBar: isDesktop ? null : _buildBottomNav(), + ); + } + + PreferredSizeWidget _buildAppBar() { + return AppBar( + title: Row( + children: [ + // Logo + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + gradient: AppColors.primaryGradient, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.live_tv, + color: Colors.black, + size: 18, + ), ), - NavigationDestination( - icon: Icon(Icons.movie), - label: 'Movies', - ), - NavigationDestination( - icon: Icon(Icons.tv), - label: 'Series', - ), - NavigationDestination( - icon: Icon(Icons.settings), - label: 'Settings', + const SizedBox(width: 12), + // Title + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'XtremFlow', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + Text( + widget.playlist.name, + style: TextStyle( + fontSize: 11, + color: AppColors.textSecondary, + fontWeight: FontWeight.w400, + ), + ), + ], ), ], ), + actions: [ + // Search button + IconButton( + icon: const Icon(Icons.search), + onPressed: () { + // TODO: Global search + }, + ), + const SizedBox(width: 8), + ], + ); + } + + Widget _buildDesktopLayout() { + return Row( + children: [ + // Navigation Rail + NavigationRail( + selectedIndex: _selectedIndex, + onDestinationSelected: (index) { + setState(() { + _selectedIndex = index; + }); + }, + extended: true, + minExtendedWidth: 180, + leading: const SizedBox(height: AppTheme.spacing16), + destinations: _railDestinations, + ), + // Divider + const VerticalDivider(thickness: 1, width: 1), + // Content + Expanded( + child: AnimatedSwitcher( + duration: AppTheme.durationNormal, + child: _tabs[_selectedIndex], + ), + ), + ], + ); + } + + Widget _buildMobileLayout() { + return AnimatedSwitcher( + duration: AppTheme.durationNormal, + child: _tabs[_selectedIndex], + ); + } + + Widget _buildBottomNav() { + return NavigationBar( + selectedIndex: _selectedIndex, + onDestinationSelected: (index) { + setState(() { + _selectedIndex = index; + }); + }, + destinations: _destinations, ); } } diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index 147ea4b..7122bc5 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -7,6 +7,8 @@ import '../providers/settings_provider.dart'; import '../screens/player_screen.dart'; import '../../../core/models/iptv_models.dart'; import '../../../core/models/playlist_config.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/utils/responsive_layout.dart'; import 'epg_widget.dart'; /// Live TV tab with category box grid navigation @@ -97,14 +99,22 @@ class _LiveTVTabState extends ConsumerState ); } + // Responsive grid columns + final gridColumns = ResponsiveLayout.value( + context, + mobile: 2, + tablet: 4, + desktop: 6, + ); + return Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(12.0), child: GridView.builder( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 6, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - childAspectRatio: 1.8, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: gridColumns, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 1.6, ), itemCount: categories.length, itemBuilder: (context, index) { diff --git a/lib/main.dart b/lib/main.dart index f5745bd..5d0dbfc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,12 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'core/router/app_router.dart'; +import 'core/theme/app_theme.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - - // Note: Hive is no longer needed for auth - using API backend now - runApp(const ProviderScope(child: MyApp())); } @@ -20,33 +18,9 @@ class MyApp extends ConsumerWidget { return MaterialApp.router( title: 'XtremFlow IPTV', debugShowCheckedModeBanner: false, - theme: ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.blue, - brightness: Brightness.light, - ), - cardTheme: CardThemeData( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - darkTheme: ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.blue, - brightness: Brightness.dark, - ), - cardTheme: CardThemeData( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - themeMode: ThemeMode.system, + theme: AppTheme.darkTheme, + darkTheme: AppTheme.darkTheme, + themeMode: ThemeMode.dark, // Force dark mode for premium look routerConfig: router, ); }