Merge pull request #43 from R0m1k3/antigravity

feat: Establish initial application structure, theming, responsive ut…
This commit is contained in:
LogiFlow authored and GitHub committed 2025-12-06 16:39:15 +01:00
commit ee455a4aef
10 files changed
+1754 -332

No files matched your search

+148
View File
@@ -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<List<AppUser>> getAllUsers() async {
try {
_api.restoreToken();
final response = await _api.get('/api/users');
final data = response.data as Map<String, dynamic>;
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<UserResult> 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<String, dynamic>;
if (data['success'] == true) {
final userData = data['user'] as Map<String, dynamic>;
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<UserResult> 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<String, dynamic>;
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<UserResult> deleteUser(String userId) async {
try {
_api.restoreToken();
final response = await _api.delete('/api/users/$userId');
final data = response.data as Map<String, dynamic>;
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<UserResult> 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<String, dynamic>;
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,
});
}
+94
View File
@@ -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<Color> 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),
);
}
+391
View File
@@ -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,
),
),
);
}
}
+122
View File
@@ -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<T>(
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,
),
),
);
}
}
@@ -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<GradientButton> createState() => _GradientButtonState();
}
class _GradientButtonState extends State<GradientButton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: AppTheme.durationFast,
vsync: this,
);
_scaleAnimation = Tween<double>(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<ShimmerLoading> createState() => _ShimmerLoadingState();
}
class _ShimmerLoadingState extends State<ShimmerLoading>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
)..repeat();
_animation = Tween<double>(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,
),
),
),
),
);
}
}
+317 -141
View File
@@ -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<List<AppUser>>((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<AdminPanel> createState() => _AdminPanelState();
}
class _AdminPanelState extends ConsumerState<AdminPanel> {
class _AdminPanelState extends ConsumerState<AdminPanel>
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<String>(
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'),
),
],
),
+198 -124
View File
@@ -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<LoginScreen> {
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<LoginScreen> {
),
),
),
),
],
),
),
);
+156 -31
View File
@@ -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<DashboardScreen> {
late final List<Widget> _tabs;
static const List<NavigationDestination> _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<NavigationRailDestination> _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<DashboardScreen> {
@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,
);
}
}
+16 -6
View File
@@ -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<LiveTVTab>
);
}
// 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) {
+4 -30
View File
@@ -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,
);
}