mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-21 05:58:05 +02:00
feat: Introduce core IPTV features, authentication screen, web player, and application theming.
This commit is contained in:
1 parent
c90ea530ea
commit
e699d3ec31
8 files changed
+917
-1365
No files matched your search
@@ -1,125 +1,99 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// XtremFlow Premium Color Palette
|
/// XtremFlow Cinematic Minimalist Palette
|
||||||
///
|
///
|
||||||
/// Modern theme with cyan/teal gradients - supports light and dark modes
|
/// A deep, dark, and neutral palette designed for content-first experiences.
|
||||||
|
/// Focuses on #0A0A0A backgrounds and subtle surfaces.
|
||||||
class AppColors {
|
class AppColors {
|
||||||
AppColors._();
|
AppColors._();
|
||||||
|
|
||||||
// ============ PRIMARY GRADIENT ============
|
// ============ BACKGROUNDS ============
|
||||||
/// Cyan primary - main accent color
|
/// Pitch black for OLED/Main backgrounds
|
||||||
static const Color primary = Color(0xFF00BCD4);
|
static const Color background = Color(0xFF0A0A0A);
|
||||||
|
|
||||||
/// Teal secondary - gradient end
|
/// Dark grey for cards/sheets
|
||||||
static const Color secondary = Color(0xFF00ACC1);
|
static const Color surface = Color(0xFF141414);
|
||||||
|
|
||||||
/// Primary gradient for buttons and highlights
|
/// Slightly lighter for hover states or active items
|
||||||
|
static const Color surfaceLight = Color(0xFF1F1F1F);
|
||||||
|
|
||||||
|
/// For inputs or secondary surfaces
|
||||||
|
static const Color surfaceVariant = Color(0xFF2A2A2A);
|
||||||
|
|
||||||
|
// ============ ACCENTS (MINIMALIST) ============
|
||||||
|
/// Primary Brand Color (Electric Blue/Violet for modern feel)
|
||||||
|
/// Replacing the old Gradient Cyan with a solid, punchy color.
|
||||||
|
static const Color primary = Color(0xFF3F51B5); // Indigo
|
||||||
|
|
||||||
|
/// Secondary accent
|
||||||
|
static const Color accent = Color(0xFF7986CB);
|
||||||
|
|
||||||
|
/// Primary Gradient (Subtle, for buttons only)
|
||||||
static const LinearGradient primaryGradient = LinearGradient(
|
static const LinearGradient primaryGradient = LinearGradient(
|
||||||
colors: [primary, secondary],
|
colors: [primary, accent],
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============ DARK MODE SURFACE COLORS ============
|
// ============ TEXT ============
|
||||||
/// Deep dark background
|
/// Pure white for high contrast headers
|
||||||
static const Color backgroundDark = Color(0xFF0A0E1A);
|
static const Color textPrimary = Color(0xFFFFFFFF);
|
||||||
|
|
||||||
/// Card/elevated surface (dark)
|
/// Light grey for body text
|
||||||
static const Color surfaceDark = Color(0xFF121829);
|
static const Color textSecondary = Color(0xFFB3B3B3);
|
||||||
|
|
||||||
/// Lighter surface variant (dark)
|
/// Darker grey for hints/disabled
|
||||||
static const Color surfaceVariantDark = Color(0xFF1E2638);
|
static const Color textTertiary = Color(0xFF808080);
|
||||||
|
|
||||||
/// Border/divider color (dark)
|
// ============ FUNCTIONAL COLORS ============
|
||||||
static const Color borderDark = Color(0xFF2A3441);
|
static const Color success = Color(0xFF4CAF50);
|
||||||
|
static const Color warning = Color(0xFFFFC107);
|
||||||
|
static const Color error = Color(0xFFCF6679);
|
||||||
|
static const Color info = Color(0xFF2196F3);
|
||||||
|
static const Color live = Color(0xFFFF0000); // Standard Red for LIVE
|
||||||
|
|
||||||
// ============ LIGHT MODE SURFACE COLORS ============
|
// ============ BORDERS ============
|
||||||
/// Light background
|
static const Color border = Color(0xFF333333);
|
||||||
static const Color backgroundLight = Color(0xFFF5F7FA);
|
|
||||||
|
|
||||||
/// Card/elevated surface (light)
|
|
||||||
static const Color surfaceLight = Color(0xFFFFFFFF);
|
|
||||||
|
|
||||||
/// Lighter surface variant (light)
|
|
||||||
static const Color surfaceVariantLight = Color(0xFFF0F2F5);
|
|
||||||
|
|
||||||
/// Border/divider color (light)
|
|
||||||
static const Color borderLight = Color(0xFFE2E8F0);
|
|
||||||
|
|
||||||
// ============ DARK MODE TEXT COLORS ============
|
|
||||||
static const Color textPrimaryDark = Color(0xFFF5F5F5);
|
|
||||||
static const Color textSecondaryDark = Color(0xFF94A3B8);
|
|
||||||
static const Color textDisabledDark = Color(0xFF64748B);
|
|
||||||
|
|
||||||
// ============ LIGHT MODE TEXT COLORS ============
|
|
||||||
static const Color textPrimaryLight = Color(0xFF1A1A2E);
|
|
||||||
static const Color textSecondaryLight = Color(0xFF64748B);
|
|
||||||
static const Color textDisabledLight = Color(0xFF94A3B8);
|
|
||||||
|
|
||||||
// ============ LEGACY ALIASES (for backwards compatibility) ============
|
|
||||||
static const Color background = backgroundDark;
|
|
||||||
static const Color surface = surfaceDark;
|
|
||||||
static const Color surfaceVariant = surfaceVariantDark;
|
|
||||||
static const Color border = borderDark;
|
|
||||||
static const Color textPrimary = textPrimaryDark;
|
|
||||||
static const Color textSecondary = textSecondaryDark;
|
|
||||||
static const Color textDisabled = textDisabledDark;
|
|
||||||
static const Color overlay = Color(0xCC0A0E14);
|
|
||||||
|
|
||||||
// ============ 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 ============
|
|
||||||
static const List<Color> categoryColors = [
|
|
||||||
Color(0xFF6366F1), // Indigo
|
|
||||||
Color(0xFFEC4899), // Pink
|
|
||||||
Color(0xFF8B5CF6), // Purple
|
|
||||||
Color(0xFF14B8A6), // Teal
|
|
||||||
Color(0xFFF59E0B), // Amber
|
|
||||||
Color(0xFF10B981), // Emerald
|
|
||||||
];
|
|
||||||
|
|
||||||
// ============ GLASSMORPHISM ============
|
// ============ GLASSMORPHISM ============
|
||||||
static const Color glassBackground = Color(0x1AFFFFFF);
|
static final Color glassBackground = const Color(0xFF1E1E1E).withOpacity(0.7);
|
||||||
static const Color glassBorder = Color(0x33FFFFFF);
|
static final Color glassBorder = const Color(0xFFFFFFFF).withOpacity(0.1);
|
||||||
static const Color glassBackgroundLight = Color(0x40FFFFFF);
|
|
||||||
static const Color glassBorderLight = Color(0x20000000);
|
|
||||||
|
|
||||||
// ============ DARK THEME COLOR SCHEME ============
|
// ============ CATEGORY COLORS (MUTED) ============
|
||||||
|
static const List<Color> categoryColors = [
|
||||||
|
Color(0xFF5C6BC0), // Indigo
|
||||||
|
Color(0xFFAB47BC), // Purple
|
||||||
|
Color(0xFFEF5350), // Red
|
||||||
|
Color(0xFF26A69A), // Teal
|
||||||
|
Color(0xFFFFA726), // Orange
|
||||||
|
Color(0xFF78909C), // Blue Grey
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============ THEME SCHEMES ============
|
||||||
static ColorScheme get darkColorScheme => const ColorScheme.dark(
|
static ColorScheme get darkColorScheme => const ColorScheme.dark(
|
||||||
primary: primary,
|
primary: primary,
|
||||||
secondary: secondary,
|
secondary: accent,
|
||||||
surface: surfaceDark,
|
surface: surface,
|
||||||
|
background: background,
|
||||||
error: error,
|
error: error,
|
||||||
onPrimary: Color(0xFF000000),
|
onPrimary: Colors.white,
|
||||||
onSecondary: Color(0xFF000000),
|
onSecondary: Colors.white,
|
||||||
onSurface: textPrimaryDark,
|
onSurface: textPrimary,
|
||||||
onError: Color(0xFFFFFFFF),
|
onError: Colors.white,
|
||||||
|
brightness: Brightness.dark,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============ LIGHT THEME COLOR SCHEME ============
|
// Minimalist Light Scheme (if ever toggled)
|
||||||
static ColorScheme get lightColorScheme => const ColorScheme.light(
|
static ColorScheme get lightColorScheme => const ColorScheme.light(
|
||||||
primary: primary,
|
primary: primary,
|
||||||
secondary: secondary,
|
secondary: accent,
|
||||||
surface: surfaceLight,
|
surface: Color(0xFFF5F5F5),
|
||||||
|
background: Colors.white,
|
||||||
error: error,
|
error: error,
|
||||||
onPrimary: Color(0xFFFFFFFF),
|
onPrimary: Colors.white,
|
||||||
onSecondary: Color(0xFFFFFFFF),
|
onSecondary: Colors.black,
|
||||||
onSurface: textPrimaryLight,
|
onSurface: Color(0xFF121212),
|
||||||
onError: Color(0xFFFFFFFF),
|
onError: Colors.white,
|
||||||
|
brightness: Brightness.light,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
+81
-511
@@ -2,38 +2,28 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'app_colors.dart';
|
import 'app_colors.dart';
|
||||||
|
|
||||||
/// XtremFlow Modern Theme
|
/// XtremFlow Cinematic Minimalist Theme
|
||||||
///
|
///
|
||||||
/// Complete theme with typography, spacing, and component theming
|
/// Refined, content-first theme. Less noise, more focus.
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
AppTheme._();
|
AppTheme._();
|
||||||
|
|
||||||
// ============ SPACING SCALE ============
|
// ============ SPACING ============
|
||||||
static const double spacing4 = 4.0;
|
static const double spacing4 = 4.0;
|
||||||
static const double spacing8 = 8.0;
|
static const double spacing8 = 8.0;
|
||||||
static const double spacing12 = 12.0;
|
static const double spacing12 = 12.0;
|
||||||
static const double spacing16 = 16.0;
|
static const double spacing16 = 16.0;
|
||||||
static const double spacing24 = 24.0;
|
static const double spacing24 = 24.0;
|
||||||
static const double spacing32 = 32.0;
|
static const double spacing32 = 32.0;
|
||||||
static const double spacing48 = 48.0;
|
|
||||||
|
|
||||||
// ============ BORDER RADIUS ============
|
// ============ RADIUS (Tighter for professional look) ============
|
||||||
static const double radiusSm = 8.0;
|
static const double radiusSm = 6.0;
|
||||||
static const double radiusMd = 12.0;
|
static const double radiusMd = 8.0;
|
||||||
static const double radiusLg = 16.0;
|
static const double radiusLg = 12.0;
|
||||||
static const double radiusXl = 24.0;
|
static const double radiusXl = 16.0;
|
||||||
static const double radiusFull = 999.0;
|
static const double radiusFull = 999.0;
|
||||||
|
|
||||||
// ============ ANIMATION DURATIONS ============
|
// ============ DARK THEME (Main) ============
|
||||||
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 {
|
static ThemeData get darkTheme {
|
||||||
final baseTheme = ThemeData.dark(useMaterial3: true);
|
final baseTheme = ThemeData.dark(useMaterial3: true);
|
||||||
|
|
||||||
@@ -41,66 +31,60 @@ class AppTheme {
|
|||||||
colorScheme: AppColors.darkColorScheme,
|
colorScheme: AppColors.darkColorScheme,
|
||||||
scaffoldBackgroundColor: AppColors.background,
|
scaffoldBackgroundColor: AppColors.background,
|
||||||
|
|
||||||
// AppBar
|
// AppBar: Minimalist, blends with background
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: AppBarTheme(
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.background, // Seamless
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
|
iconTheme: const IconThemeData(color: AppColors.textPrimary),
|
||||||
titleTextStyle: GoogleFonts.inter(
|
titleTextStyle: GoogleFonts.inter(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
iconTheme: const IconThemeData(color: AppColors.textPrimary),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// Navigation Bar (bottom)
|
// Navigation: Simple, functional
|
||||||
navigationBarTheme: NavigationBarThemeData(
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.background,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
indicatorColor: AppColors.primary.withOpacity(0.2),
|
indicatorColor: AppColors.primary.withOpacity(0.15),
|
||||||
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
||||||
if (states.contains(WidgetState.selected)) {
|
final isSelected = states.contains(WidgetState.selected);
|
||||||
return GoogleFonts.inter(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.primary,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return GoogleFonts.inter(
|
return GoogleFonts.inter(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||||
color: AppColors.textSecondary,
|
color: isSelected ? AppColors.textPrimary : AppColors.textSecondary,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
iconTheme: WidgetStateProperty.resolveWith((states) {
|
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||||
if (states.contains(WidgetState.selected)) {
|
final isSelected = states.contains(WidgetState.selected);
|
||||||
return const IconThemeData(color: AppColors.primary);
|
return IconThemeData(
|
||||||
}
|
color: isSelected ? AppColors.textPrimary : AppColors.textSecondary,
|
||||||
return const IconThemeData(color: AppColors.textSecondary);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Navigation Rail (desktop sidebar)
|
// NavigationRail (Desktop)
|
||||||
navigationRailTheme: NavigationRailThemeData(
|
navigationRailTheme: NavigationRailThemeData(
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.background,
|
||||||
indicatorColor: AppColors.primary.withOpacity(0.2),
|
indicatorColor: AppColors.primary.withOpacity(0.15),
|
||||||
selectedIconTheme: const IconThemeData(color: AppColors.primary),
|
selectedIconTheme: const IconThemeData(color: AppColors.textPrimary),
|
||||||
unselectedIconTheme: const IconThemeData(color: AppColors.textSecondary),
|
unselectedIconTheme: const IconThemeData(color: AppColors.textSecondary),
|
||||||
selectedLabelTextStyle: GoogleFonts.inter(
|
selectedLabelTextStyle: GoogleFonts.inter(
|
||||||
fontSize: 12,
|
color: AppColors.textPrimary,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.primary,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
unselectedLabelTextStyle: GoogleFonts.inter(
|
unselectedLabelTextStyle: GoogleFonts.inter(
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Cards
|
// Cards: Flat, subtle border
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
color: AppColors.surface,
|
color: AppColors.surface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
@@ -112,10 +96,11 @@ class AppTheme {
|
|||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Inputs
|
// Inputs: Clean, filled
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: AppColors.surfaceVariant,
|
fillColor: AppColors.surfaceVariant,
|
||||||
|
contentPadding: const EdgeInsets.all(spacing16),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(radiusSm),
|
borderRadius: BorderRadius.circular(radiusSm),
|
||||||
borderSide: const BorderSide(color: AppColors.border),
|
borderSide: const BorderSide(color: AppColors.border),
|
||||||
@@ -126,498 +111,83 @@ class AppTheme {
|
|||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(radiusSm),
|
borderRadius: BorderRadius.circular(radiusSm),
|
||||||
borderSide: const BorderSide(color: AppColors.primary, width: 2),
|
borderSide: const BorderSide(color: AppColors.primary),
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
|
hintStyle: GoogleFonts.inter(color: AppColors.textTertiary),
|
||||||
|
labelStyle: GoogleFonts.inter(color: AppColors.textSecondary),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Buttons
|
// Buttons: Sharp, reliable
|
||||||
filledButtonTheme: FilledButtonThemeData(
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: AppColors.primary,
|
backgroundColor: AppColors.primary,
|
||||||
foregroundColor: Colors.black,
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: spacing24,
|
|
||||||
vertical: spacing12,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(radiusSm),
|
borderRadius: BorderRadius.circular(radiusSm),
|
||||||
),
|
),
|
||||||
textStyle: GoogleFonts.inter(
|
textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600),
|
||||||
fontSize: 14,
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: AppColors.primary,
|
foregroundColor: AppColors.textPrimary,
|
||||||
side: const BorderSide(color: AppColors.primary),
|
side: const BorderSide(color: AppColors.border),
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: spacing24,
|
|
||||||
vertical: spacing12,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(radiusSm),
|
borderRadius: BorderRadius.circular(radiusSm),
|
||||||
),
|
),
|
||||||
textStyle: GoogleFonts.inter(
|
textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600),
|
||||||
fontSize: 14,
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
textButtonTheme: TextButtonThemeData(
|
// Text
|
||||||
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(
|
textTheme: TextTheme(
|
||||||
displayLarge: GoogleFonts.inter(
|
displayLarge: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.bold),
|
||||||
fontSize: 57,
|
displayMedium: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.bold),
|
||||||
fontWeight: FontWeight.w400,
|
headlineMedium: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.w600),
|
||||||
color: AppColors.textPrimary,
|
titleLarge: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.w600),
|
||||||
),
|
titleMedium: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.w500),
|
||||||
displayMedium: GoogleFonts.inter(
|
bodyLarge: GoogleFonts.inter(color: AppColors.textSecondary, fontSize: 16),
|
||||||
fontSize: 45,
|
bodyMedium: GoogleFonts.inter(color: AppColors.textSecondary, fontSize: 14),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ LIGHT THEME ============
|
// ============ LIGHT THEME (Minimalist) ============
|
||||||
static ThemeData get lightTheme {
|
static ThemeData get lightTheme {
|
||||||
final baseTheme = ThemeData.light(useMaterial3: true);
|
final baseTheme = ThemeData.light(useMaterial3: true);
|
||||||
|
final colors = AppColors.lightColorScheme;
|
||||||
|
|
||||||
return baseTheme.copyWith(
|
return baseTheme.copyWith(
|
||||||
colorScheme: AppColors.lightColorScheme,
|
colorScheme: colors,
|
||||||
scaffoldBackgroundColor: AppColors.backgroundLight,
|
scaffoldBackgroundColor: colors.background,
|
||||||
|
appBarTheme: AppBarTheme(
|
||||||
// AppBar
|
backgroundColor: colors.background,
|
||||||
appBarTheme: AppBarTheme(
|
foregroundColor: colors.onSurface,
|
||||||
backgroundColor: AppColors.surfaceLight,
|
elevation: 0,
|
||||||
surfaceTintColor: Colors.transparent,
|
centerTitle: false,
|
||||||
elevation: 0,
|
iconTheme: IconThemeData(color: colors.onSurface),
|
||||||
centerTitle: false,
|
titleTextStyle: GoogleFonts.inter(
|
||||||
titleTextStyle: GoogleFonts.inter(
|
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textPrimaryLight,
|
color: colors.onSurface,
|
||||||
),
|
),
|
||||||
iconTheme: const IconThemeData(color: AppColors.textPrimaryLight),
|
),
|
||||||
),
|
cardTheme: CardThemeData(
|
||||||
|
color: colors.surface,
|
||||||
// Navigation Bar (bottom)
|
elevation: 0,
|
||||||
navigationBarTheme: NavigationBarThemeData(
|
shape: RoundedRectangleBorder(
|
||||||
backgroundColor: AppColors.surfaceLight,
|
borderRadius: BorderRadius.circular(radiusMd),
|
||||||
surfaceTintColor: Colors.transparent,
|
side: BorderSide(color: Colors.grey.shade300),
|
||||||
indicatorColor: AppColors.primary.withOpacity(0.15),
|
),
|
||||||
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
),
|
||||||
if (states.contains(WidgetState.selected)) {
|
textTheme: TextTheme(
|
||||||
return GoogleFonts.inter(
|
bodyLarge: GoogleFonts.inter(color: colors.onSurface),
|
||||||
fontSize: 12,
|
bodyMedium: GoogleFonts.inter(color: colors.onSurfaceVariant),
|
||||||
fontWeight: FontWeight.w600,
|
),
|
||||||
color: AppColors.primary,
|
// Can expand light theme details later if needed
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return GoogleFonts.inter(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: AppColors.textSecondaryLight,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
iconTheme: WidgetStateProperty.resolveWith((states) {
|
|
||||||
if (states.contains(WidgetState.selected)) {
|
|
||||||
return const IconThemeData(color: AppColors.primary);
|
|
||||||
}
|
|
||||||
return const IconThemeData(color: AppColors.textSecondaryLight);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Navigation Rail (desktop sidebar)
|
|
||||||
navigationRailTheme: NavigationRailThemeData(
|
|
||||||
backgroundColor: AppColors.surfaceLight,
|
|
||||||
indicatorColor: AppColors.primary.withOpacity(0.15),
|
|
||||||
selectedIconTheme: const IconThemeData(color: AppColors.primary),
|
|
||||||
unselectedIconTheme: const IconThemeData(color: AppColors.textSecondaryLight),
|
|
||||||
selectedLabelTextStyle: GoogleFonts.inter(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.primary,
|
|
||||||
),
|
|
||||||
unselectedLabelTextStyle: GoogleFonts.inter(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: AppColors.textSecondaryLight,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Cards
|
|
||||||
cardTheme: CardThemeData(
|
|
||||||
color: AppColors.surfaceLight,
|
|
||||||
surfaceTintColor: Colors.transparent,
|
|
||||||
elevation: 2,
|
|
||||||
shadowColor: Colors.black.withOpacity(0.08),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(radiusMd),
|
|
||||||
),
|
|
||||||
margin: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
|
|
||||||
// Inputs
|
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
|
||||||
filled: true,
|
|
||||||
fillColor: AppColors.surfaceVariantLight,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(radiusSm),
|
|
||||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(radiusSm),
|
|
||||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
|
||||||
),
|
|
||||||
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.textDisabledLight,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
labelStyle: GoogleFonts.inter(
|
|
||||||
color: AppColors.textSecondaryLight,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Buttons
|
|
||||||
filledButtonTheme: FilledButtonThemeData(
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: AppColors.primary,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: spacing24,
|
|
||||||
vertical: spacing12,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(radiusSm),
|
|
||||||
),
|
|
||||||
textStyle: GoogleFonts.inter(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Dialogs
|
|
||||||
dialogTheme: DialogThemeData(
|
|
||||||
backgroundColor: AppColors.surfaceLight,
|
|
||||||
surfaceTintColor: Colors.transparent,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(radiusLg),
|
|
||||||
),
|
|
||||||
titleTextStyle: GoogleFonts.inter(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimaryLight,
|
|
||||||
),
|
|
||||||
contentTextStyle: GoogleFonts.inter(
|
|
||||||
fontSize: 14,
|
|
||||||
color: AppColors.textSecondaryLight,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Dividers
|
|
||||||
dividerTheme: const DividerThemeData(
|
|
||||||
color: AppColors.borderLight,
|
|
||||||
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.textPrimaryLight,
|
|
||||||
),
|
|
||||||
subtitleTextStyle: GoogleFonts.inter(
|
|
||||||
fontSize: 12,
|
|
||||||
color: AppColors.textSecondaryLight,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(radiusSm),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Progress indicators
|
|
||||||
progressIndicatorTheme: const ProgressIndicatorThemeData(
|
|
||||||
color: AppColors.primary,
|
|
||||||
linearTrackColor: AppColors.surfaceVariantLight,
|
|
||||||
circularTrackColor: AppColors.surfaceVariantLight,
|
|
||||||
),
|
|
||||||
|
|
||||||
// Text theme
|
|
||||||
textTheme: TextTheme(
|
|
||||||
displayLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 57,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
color: AppColors.textPrimaryLight,
|
|
||||||
),
|
|
||||||
headlineLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimaryLight,
|
|
||||||
),
|
|
||||||
titleLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimaryLight,
|
|
||||||
),
|
|
||||||
titleMedium: GoogleFonts.inter(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimaryLight,
|
|
||||||
),
|
|
||||||
bodyLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
color: AppColors.textPrimaryLight,
|
|
||||||
),
|
|
||||||
bodyMedium: GoogleFonts.inter(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
color: AppColors.textPrimaryLight,
|
|
||||||
),
|
|
||||||
bodySmall: GoogleFonts.inter(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
color: AppColors.textSecondaryLight,
|
|
||||||
),
|
|
||||||
labelLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimaryLight,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,241 +45,210 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Container(
|
backgroundColor: AppColors.background,
|
||||||
decoration: const BoxDecoration(
|
body: Stack(
|
||||||
gradient: LinearGradient(
|
children: [
|
||||||
begin: Alignment.topLeft,
|
// Ambient background glow (Subtle)
|
||||||
end: Alignment.bottomRight,
|
Positioned(
|
||||||
colors: [
|
top: -100,
|
||||||
Color(0xFF0F1923), // Deep dark blue
|
left: -100,
|
||||||
AppColors.background,
|
child: Container(
|
||||||
Color(0xFF0A1628), // Navy accent
|
width: 500,
|
||||||
],
|
height: 500,
|
||||||
stops: [0.0, 0.5, 1.0],
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: RadialGradient(
|
||||||
|
colors: [
|
||||||
|
AppColors.primary.withOpacity(0.15),
|
||||||
|
Colors.transparent,
|
||||||
|
],
|
||||||
|
radius: 0.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
child: Stack(
|
// Main content
|
||||||
children: [
|
Center(
|
||||||
// Glow effect top
|
child: SingleChildScrollView(
|
||||||
Positioned(
|
padding: const EdgeInsets.all(24.0),
|
||||||
top: -100,
|
|
||||||
left: -100,
|
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 300,
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
height: 300,
|
child: Column(
|
||||||
decoration: BoxDecoration(
|
mainAxisSize: MainAxisSize.min,
|
||||||
shape: BoxShape.circle,
|
children: [
|
||||||
gradient: RadialGradient(
|
// Logo and title
|
||||||
colors: [
|
Container(
|
||||||
AppColors.primary.withOpacity(0.1),
|
width: 80,
|
||||||
Colors.transparent,
|
height: 80,
|
||||||
],
|
decoration: BoxDecoration(
|
||||||
),
|
color: AppColors.surface,
|
||||||
),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
border: Border.all(color: AppColors.border),
|
||||||
),
|
boxShadow: [
|
||||||
// Glow effect bottom
|
BoxShadow(
|
||||||
Positioned(
|
color: AppColors.primary.withOpacity(0.2),
|
||||||
bottom: -150,
|
blurRadius: 30,
|
||||||
right: -150,
|
offset: const Offset(0, 10),
|
||||||
child: Container(
|
),
|
||||||
width: 400,
|
],
|
||||||
height: 400,
|
),
|
||||||
decoration: BoxDecoration(
|
child: const Icon(
|
||||||
shape: BoxShape.circle,
|
Icons.live_tv,
|
||||||
gradient: RadialGradient(
|
color: AppColors.primary,
|
||||||
colors: [
|
size: 40,
|
||||||
AppColors.secondary.withOpacity(0.08),
|
),
|
||||||
Colors.transparent,
|
),
|
||||||
],
|
const SizedBox(height: 24),
|
||||||
),
|
const Text(
|
||||||
),
|
'XtremFlow',
|
||||||
),
|
style: TextStyle(
|
||||||
),
|
fontSize: 32,
|
||||||
// Main content
|
fontWeight: FontWeight.w800,
|
||||||
Center(
|
color: AppColors.textPrimary,
|
||||||
child: SingleChildScrollView(
|
letterSpacing: -0.5,
|
||||||
padding: const EdgeInsets.all(24.0),
|
),
|
||||||
child: Container(
|
),
|
||||||
constraints: const BoxConstraints(maxWidth: 400),
|
const SizedBox(height: 8),
|
||||||
child: Column(
|
const Text(
|
||||||
mainAxisSize: MainAxisSize.min,
|
'Cinematic Experience',
|
||||||
children: [
|
style: TextStyle(
|
||||||
// Logo and title
|
fontSize: 14,
|
||||||
Container(
|
color: AppColors.textSecondary,
|
||||||
width: 80,
|
letterSpacing: 2,
|
||||||
height: 80,
|
fontWeight: FontWeight.w500,
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
gradient: AppColors.primaryGradient,
|
),
|
||||||
borderRadius: BorderRadius.circular(20),
|
const SizedBox(height: 48),
|
||||||
boxShadow: [
|
// Login form
|
||||||
BoxShadow(
|
GlassCard(
|
||||||
color: AppColors.primary.withOpacity(0.4),
|
padding: const EdgeInsets.all(32),
|
||||||
blurRadius: 30,
|
borderRadius: 16,
|
||||||
offset: const Offset(0, 10),
|
showBorder: true,
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Welcome Back',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
TextFormField(
|
||||||
|
controller: _usernameController,
|
||||||
|
style: const TextStyle(color: AppColors.textPrimary),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Username',
|
||||||
|
prefixIcon: const Icon(Icons.person_outline),
|
||||||
|
fillColor: AppColors.surface,
|
||||||
|
hoverColor: AppColors.surfaceLight,
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
style: const TextStyle(color: AppColors.textPrimary),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Password',
|
||||||
|
prefixIcon: const Icon(Icons.lock_outline),
|
||||||
|
fillColor: AppColors.surface,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_obscurePassword
|
||||||
|
? Icons.visibility_outlined
|
||||||
|
: Icons.visibility_off_outlined,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Password required';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onFieldSubmitted: (_) => _handleLogin(),
|
||||||
|
),
|
||||||
|
if (authState.errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
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(height: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
authState.errorMessage!,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.error,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
GradientButton(
|
||||||
|
label: 'Sign In',
|
||||||
|
icon: Icons.arrow_forward,
|
||||||
|
isLoading: authState.isLoading,
|
||||||
|
isExpanded: true,
|
||||||
|
onPressed: _handleLogin,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: const Icon(
|
|
||||||
Icons.live_tv,
|
|
||||||
color: Colors.black,
|
|
||||||
size: 40,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
),
|
||||||
ShaderMask(
|
const SizedBox(height: 24),
|
||||||
shaderCallback: (bounds) =>
|
const Text(
|
||||||
AppColors.primaryGradient.createShader(bounds),
|
'Default: admin / admin',
|
||||||
child: const Text(
|
style: TextStyle(
|
||||||
'XtremFlow',
|
fontSize: 12,
|
||||||
style: TextStyle(
|
color: AppColors.textTertiary,
|
||||||
fontSize: 36,
|
fontStyle: FontStyle.italic,
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
textAlign: TextAlign.center,
|
||||||
Text(
|
),
|
||||||
'Streaming IPTV Premium',
|
],
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
letterSpacing: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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: [
|
|
||||||
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),
|
|
||||||
Text(
|
|
||||||
'Identifiants par défaut: admin / admin',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: AppColors.textDisabled,
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Manages the list of favorite channel IDs
|
||||||
|
class FavoritesNotifier extends StateNotifier<List<String>> {
|
||||||
|
FavoritesNotifier() : super([]) {
|
||||||
|
_loadFavorites();
|
||||||
|
}
|
||||||
|
|
||||||
|
static const String _key = 'xtremflow_favorites';
|
||||||
|
|
||||||
|
Future<void> _loadFavorites() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final favorites = prefs.getStringList(_key) ?? [];
|
||||||
|
state = favorites;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleFavorite(String streamId) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (state.contains(streamId)) {
|
||||||
|
state = state.where((id) => id != streamId).toList();
|
||||||
|
} else {
|
||||||
|
state = [...state, streamId];
|
||||||
|
}
|
||||||
|
await prefs.setStringList(_key, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isFavorite(String streamId) {
|
||||||
|
return state.contains(streamId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final favoritesProvider = StateNotifierProvider<FavoritesNotifier, List<String>>((ref) {
|
||||||
|
return FavoritesNotifier();
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../providers/favorites_provider.dart';
|
||||||
|
|
||||||
/// Premium channel card with Netflix-style design
|
/// Premium channel card with Netflix-style design
|
||||||
///
|
///
|
||||||
@@ -9,8 +11,10 @@ import '../../../core/theme/app_colors.dart';
|
|||||||
/// - Thumbnail/logo with fallback
|
/// - Thumbnail/logo with fallback
|
||||||
/// - Live badge with pulse animation
|
/// - Live badge with pulse animation
|
||||||
/// - EPG overlay
|
/// - EPG overlay
|
||||||
|
/// - Favorite toggle
|
||||||
/// - Hover/tap effects
|
/// - Hover/tap effects
|
||||||
class ChannelCard extends StatefulWidget {
|
class ChannelCard extends ConsumerStatefulWidget {
|
||||||
|
final String streamId;
|
||||||
final String name;
|
final String name;
|
||||||
final String? iconUrl;
|
final String? iconUrl;
|
||||||
final String? currentProgram;
|
final String? currentProgram;
|
||||||
@@ -21,6 +25,7 @@ class ChannelCard extends StatefulWidget {
|
|||||||
|
|
||||||
const ChannelCard({
|
const ChannelCard({
|
||||||
super.key,
|
super.key,
|
||||||
|
required this.streamId,
|
||||||
required this.name,
|
required this.name,
|
||||||
this.iconUrl,
|
this.iconUrl,
|
||||||
this.currentProgram,
|
this.currentProgram,
|
||||||
@@ -31,10 +36,10 @@ class ChannelCard extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChannelCard> createState() => _ChannelCardState();
|
ConsumerState<ChannelCard> createState() => _ChannelCardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ChannelCardState extends State<ChannelCard>
|
class _ChannelCardState extends ConsumerState<ChannelCard>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
bool _isHovered = false;
|
bool _isHovered = false;
|
||||||
late AnimationController _pulseController;
|
late AnimationController _pulseController;
|
||||||
@@ -61,8 +66,8 @@ class _ChannelCardState extends State<ChannelCard>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final favorites = ref.watch(favoritesProvider);
|
||||||
final isDark = theme.brightness == Brightness.dark;
|
final isFavorite = favorites.contains(widget.streamId);
|
||||||
|
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
onEnter: (_) => setState(() => _isHovered = true),
|
onEnter: (_) => setState(() => _isHovered = true),
|
||||||
@@ -77,49 +82,31 @@ class _ChannelCardState extends State<ChannelCard>
|
|||||||
transform: Matrix4.identity()..scale(_isHovered ? 1.05 : 1.0),
|
transform: Matrix4.identity()..scale(_isHovered ? 1.05 : 1.0),
|
||||||
transformAlignment: Alignment.center,
|
transformAlignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(8),
|
||||||
gradient: LinearGradient(
|
color: AppColors.surface, // Clean flat dark surface
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: isDark
|
|
||||||
? [
|
|
||||||
AppColors.surfaceDark.withOpacity(0.95),
|
|
||||||
AppColors.surfaceVariantDark.withOpacity(0.9),
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
Colors.white.withOpacity(0.95),
|
|
||||||
AppColors.surfaceVariantLight.withOpacity(0.9),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _isHovered
|
color: _isHovered
|
||||||
? AppColors.primary.withOpacity(0.5)
|
? AppColors.primary
|
||||||
: (isDark ? AppColors.borderDark : AppColors.borderLight),
|
: AppColors.border,
|
||||||
width: _isHovered ? 1.5 : 1,
|
width: _isHovered ? 2 : 1,
|
||||||
),
|
),
|
||||||
boxShadow: _isHovered
|
boxShadow: _isHovered
|
||||||
? [
|
? [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.primary.withOpacity(0.2),
|
color: AppColors.primary.withOpacity(0.3),
|
||||||
blurRadius: 20,
|
blurRadius: 16,
|
||||||
offset: const Offset(0, 8),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(isDark ? 0.3 : 0.1),
|
|
||||||
blurRadius: 8,
|
|
||||||
offset: const Offset(0, 4),
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
],
|
]
|
||||||
|
: [],
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(11),
|
borderRadius: BorderRadius.circular(7), // Inner radius
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// Channel logo/thumbnail
|
// Channel logo/thumbnail
|
||||||
_buildChannelImage(isDark),
|
_buildChannelImage(),
|
||||||
|
|
||||||
// Gradient overlay for text readability
|
// Gradient overlay for text readability
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
@@ -129,10 +116,10 @@ class _ChannelCardState extends State<ChannelCard>
|
|||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [
|
||||||
Colors.transparent,
|
Colors.black.withOpacity(0.2),
|
||||||
Colors.black.withOpacity(0.7),
|
Colors.black.withOpacity(0.9),
|
||||||
],
|
],
|
||||||
stops: const [0.4, 1.0],
|
stops: const [0.0, 1.0],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -146,7 +133,23 @@ class _ChannelCardState extends State<ChannelCard>
|
|||||||
child: _buildLiveBadge(),
|
child: _buildLiveBadge(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Channel info overlay
|
// Favorite Icon
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||||
|
size: 20,
|
||||||
|
color: isFavorite ? AppColors.primary : Colors.white70,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(favoritesProvider.notifier).toggleFavorite(widget.streamId);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Channel info
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 10,
|
left: 10,
|
||||||
right: 10,
|
right: 10,
|
||||||
@@ -159,26 +162,20 @@ class _ChannelCardState extends State<ChannelCard>
|
|||||||
widget.name,
|
widget.name,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 13,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w700,
|
||||||
shadows: [
|
|
||||||
Shadow(
|
|
||||||
color: Colors.black54,
|
|
||||||
blurRadius: 4,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
if (widget.currentProgram != null) ...[
|
if (widget.currentProgram != null && widget.currentProgram!.isNotEmpty) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
widget.currentProgram!,
|
widget.currentProgram!,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white.withOpacity(0.8),
|
color: Colors.white.withOpacity(0.7),
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -195,26 +192,26 @@ class _ChannelCardState extends State<ChannelCard>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildChannelImage(bool isDark) {
|
Widget _buildChannelImage() {
|
||||||
if (widget.iconUrl != null && widget.iconUrl!.isNotEmpty) {
|
if (widget.iconUrl != null && widget.iconUrl!.isNotEmpty) {
|
||||||
return CachedNetworkImage(
|
return CachedNetworkImage(
|
||||||
imageUrl: widget.iconUrl!,
|
imageUrl: widget.iconUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.contain, // Contain usually looks better for channel logos
|
||||||
placeholder: (context, url) => _buildPlaceholder(isDark),
|
placeholder: (context, url) => _buildPlaceholder(),
|
||||||
errorWidget: (context, url, error) => _buildPlaceholder(isDark),
|
errorWidget: (context, url, error) => _buildPlaceholder(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return _buildPlaceholder(isDark);
|
return _buildPlaceholder();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPlaceholder(bool isDark) {
|
Widget _buildPlaceholder() {
|
||||||
return Container(
|
return Container(
|
||||||
color: isDark ? AppColors.surfaceVariantDark : AppColors.surfaceVariantLight,
|
color: AppColors.surfaceVariant,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.live_tv_rounded,
|
Icons.tv,
|
||||||
size: 36,
|
size: 32,
|
||||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
color: AppColors.textTertiary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -225,40 +222,21 @@ class _ChannelCardState extends State<ChannelCard>
|
|||||||
animation: _pulseAnimation,
|
animation: _pulseAnimation,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.live.withOpacity(_pulseAnimation.value),
|
color: AppColors.live.withOpacity(_pulseAnimation.value),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: AppColors.live.withOpacity(0.4 * _pulseAnimation.value),
|
|
||||||
blurRadius: 8,
|
|
||||||
spreadRadius: 1,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Row(
|
child: const Text(
|
||||||
mainAxisSize: MainAxisSize.min,
|
'LIVE',
|
||||||
children: [
|
style: TextStyle(
|
||||||
Icon(
|
color: Colors.white,
|
||||||
Icons.circle,
|
fontSize: 9,
|
||||||
size: 6,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.white,
|
),
|
||||||
),
|
|
||||||
SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
'LIVE',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import '../providers/xtream_provider.dart';
|
import '../providers/xtream_provider.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
|
import '../providers/favorites_provider.dart';
|
||||||
import '../screens/player_screen.dart';
|
import '../screens/player_screen.dart';
|
||||||
import '../../../core/models/iptv_models.dart';
|
import '../../../core/models/iptv_models.dart';
|
||||||
import '../../../core/models/playlist_config.dart';
|
import '../../../core/models/playlist_config.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/utils/responsive_layout.dart';
|
import '../../../core/utils/responsive_layout.dart';
|
||||||
import 'epg_widget.dart';
|
import 'channel_card.dart';
|
||||||
|
|
||||||
/// Live TV tab with category box grid navigation
|
/// Live TV tab - Enhanced with Search, Favorites and Grid/List View
|
||||||
class LiveTVTab extends ConsumerStatefulWidget {
|
class LiveTVTab extends ConsumerStatefulWidget {
|
||||||
final PlaylistConfig playlist;
|
final PlaylistConfig playlist;
|
||||||
|
|
||||||
@@ -23,9 +23,34 @@ class LiveTVTab extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||||
with AutomaticKeepAliveClientMixin {
|
with AutomaticKeepAliveClientMixin {
|
||||||
|
|
||||||
|
// State
|
||||||
String? _selectedCategory;
|
String? _selectedCategory;
|
||||||
int _currentPage = 0;
|
int _currentPage = 0;
|
||||||
static const int _itemsPerPage = 50;
|
static const int _itemsPerPage = 100; // Increased for grid view
|
||||||
|
|
||||||
|
// UI State
|
||||||
|
bool _isGridView = true;
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
bool _showFavoritesOnly = false;
|
||||||
|
String _searchQuery = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_searchController.addListener(() {
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = _searchController.text.toLowerCase();
|
||||||
|
_currentPage = 0; // Reset page on search
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true;
|
bool get wantKeepAlive => true;
|
||||||
@@ -35,391 +60,388 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
super.build(context);
|
super.build(context);
|
||||||
|
|
||||||
final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
|
final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
|
||||||
|
final favorites = ref.watch(favoritesProvider);
|
||||||
|
|
||||||
return channelsAsync.when(
|
return Scaffold( // Use scaffold for toolbar body structure
|
||||||
data: (groupedChannels) {
|
backgroundColor: Colors.transparent,
|
||||||
if (groupedChannels.isEmpty) {
|
body: Column(
|
||||||
return const Center(
|
children: [
|
||||||
child: Text('No live channels available'),
|
// Toolbar
|
||||||
);
|
_buildToolbar(),
|
||||||
}
|
|
||||||
|
|
||||||
// If a category is selected, show channels list
|
// Content
|
||||||
if (_selectedCategory != null && groupedChannels.containsKey(_selectedCategory)) {
|
Expanded(
|
||||||
return _buildChannelsList(groupedChannels[_selectedCategory]!);
|
child: channelsAsync.when(
|
||||||
}
|
data: (groupedChannels) {
|
||||||
|
// 1. Flatten for global search/filter if needed
|
||||||
|
final allChannels = groupedChannels.values.expand((element) => element).toList();
|
||||||
|
|
||||||
// Otherwise show category grid
|
List<Channel> displayedChannels = [];
|
||||||
return _buildCategoryGrid(groupedChannels);
|
bool isGlobalMode = _searchQuery.isNotEmpty || _showFavoritesOnly;
|
||||||
},
|
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
if (isGlobalMode) {
|
||||||
error: (error, stack) => Center(
|
// Global Filter Mode
|
||||||
child: Column(
|
displayedChannels = allChannels.where((channel) {
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
final matchesSearch = _searchQuery.isEmpty ||
|
||||||
children: [
|
channel.name.toLowerCase().contains(_searchQuery);
|
||||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
final matchesFavorite = !_showFavoritesOnly ||
|
||||||
const SizedBox(height: 16),
|
favorites.contains(channel.streamId);
|
||||||
Text('Error loading channels: $error'),
|
return matchesSearch && matchesFavorite;
|
||||||
],
|
}).toList();
|
||||||
),
|
} else if (_selectedCategory != null) {
|
||||||
|
// Category Mode
|
||||||
|
displayedChannels = groupedChannels[_selectedCategory] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no global mode and no category selected, show Category Grid
|
||||||
|
if (!isGlobalMode && _selectedCategory == null) {
|
||||||
|
if (groupedChannels.isEmpty) {
|
||||||
|
return const Center(child: Text('No channels found'));
|
||||||
|
}
|
||||||
|
return _buildCategoryGrid(groupedChannels);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show Channels (Grid or List)
|
||||||
|
if (displayedChannels.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.tv_off, size: 64, color: AppColors.textTertiary),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_showFavoritesOnly ? 'No favorites found' : 'No channels found',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildChannelsView(displayedChannels, isGlobalMode);
|
||||||
|
},
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, s) => Center(child: Text('Error: $e')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the category grid view with box cards
|
Widget _buildToolbar() {
|
||||||
Widget _buildCategoryGrid(Map<String, List<Channel>> groupedChannels) {
|
return Container(
|
||||||
// Get filter settings
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
final settings = ref.watch(iptvSettingsProvider);
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
border: Border(bottom: BorderSide(color: AppColors.border)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Search Bar
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search channels...',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
suffixIcon: _searchQuery.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () => _searchController.clear(),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
isDense: true,
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColors.surfaceVariant,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
// Filter categories based on keywords
|
// Favorites Toggle
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Favorites Only',
|
||||||
|
icon: Icon(
|
||||||
|
_showFavoritesOnly ? Icons.favorite : Icons.favorite_border,
|
||||||
|
color: _showFavoritesOnly ? AppColors.primary : AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_showFavoritesOnly = !_showFavoritesOnly;
|
||||||
|
_selectedCategory = null; // Reset category when toggling favorites
|
||||||
|
_currentPage = 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// View Toggle
|
||||||
|
IconButton(
|
||||||
|
tooltip: _isGridView ? 'List View' : 'Grid View',
|
||||||
|
icon: Icon(
|
||||||
|
_isGridView ? Icons.view_list : Icons.grid_view,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
onPressed: () => setState(() => _isGridView = !_isGridView),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCategoryGrid(Map<String, List<Channel>> groupedChannels) {
|
||||||
|
final settings = ref.watch(iptvSettingsProvider);
|
||||||
var categories = groupedChannels.keys.toList();
|
var categories = groupedChannels.keys.toList();
|
||||||
|
|
||||||
|
// Apply Settings Filter (Hidden categories)
|
||||||
if (settings.liveTvKeywords.isNotEmpty) {
|
if (settings.liveTvKeywords.isNotEmpty) {
|
||||||
categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList();
|
categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList();
|
||||||
}
|
}
|
||||||
categories.sort();
|
categories.sort();
|
||||||
|
|
||||||
if (categories.isEmpty) {
|
final crossAxisCount = ResponsiveLayout.value(
|
||||||
return Center(
|
context,
|
||||||
child: Column(
|
mobile: 2,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
tablet: 3,
|
||||||
children: [
|
desktop: 5,
|
||||||
const Icon(Icons.filter_list_off, size: 48, color: Colors.grey),
|
);
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No categories match the filter',
|
|
||||||
style: GoogleFonts.roboto(color: Colors.grey),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Filter: ${settings.liveTvCategoryFilter}',
|
|
||||||
style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responsive grid columns
|
return GridView.builder(
|
||||||
final gridColumns = ResponsiveLayout.value(
|
padding: const EdgeInsets.all(16),
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: crossAxisCount,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
crossAxisSpacing: 16,
|
||||||
|
childAspectRatio: 1.5,
|
||||||
|
),
|
||||||
|
itemCount: categories.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final category = categories[index];
|
||||||
|
final count = groupedChannels[category]!.length;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => setState(() {
|
||||||
|
_selectedCategory = category;
|
||||||
|
_currentPage = 0;
|
||||||
|
}),
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
AppColors.surfaceLight,
|
||||||
|
AppColors.surface,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
right: -10,
|
||||||
|
bottom: -10,
|
||||||
|
child: Icon(
|
||||||
|
Icons.tv,
|
||||||
|
size: 80,
|
||||||
|
color: Colors.white.withOpacity(0.03),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
category,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primary.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'$count channels',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.primary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChannelsView(List<Channel> channels, bool isGlobalMode) {
|
||||||
|
// Pagination
|
||||||
|
final totalPages = (channels.length / _itemsPerPage).ceil();
|
||||||
|
final startIndex = _currentPage * _itemsPerPage;
|
||||||
|
final endIndex = (startIndex + _itemsPerPage > channels.length)
|
||||||
|
? channels.length
|
||||||
|
: startIndex + _itemsPerPage;
|
||||||
|
final pageChannels = channels.sublist(startIndex, endIndex);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Breadcrumb / Back Navigation
|
||||||
|
if (!isGlobalMode && _selectedCategory != null)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
color: AppColors.surfaceVariant.withOpacity(0.5),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_ios, size: 16),
|
||||||
|
onPressed: () => setState(() => _selectedCategory = null),
|
||||||
|
tooltip: 'Back to Categories',
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_selectedCategory!,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'${channels.length} items',
|
||||||
|
style: GoogleFonts.inter(color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Grid/List Content
|
||||||
|
Expanded(
|
||||||
|
child: _isGridView
|
||||||
|
? _buildGridView(pageChannels)
|
||||||
|
: _buildListView(pageChannels),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Pagination Controls
|
||||||
|
if (totalPages > 1)
|
||||||
|
Container(
|
||||||
|
height: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
border: Border(top: BorderSide(color: AppColors.border)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.chevron_left),
|
||||||
|
onPressed: _currentPage > 0 ? () => setState(() => _currentPage--) : null,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Page ${_currentPage + 1} of $totalPages',
|
||||||
|
style: GoogleFonts.inter(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.chevron_right),
|
||||||
|
onPressed: _currentPage < totalPages - 1 ? () => setState(() => _currentPage++) : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGridView(List<Channel> channels) {
|
||||||
|
final columns = ResponsiveLayout.value(
|
||||||
context,
|
context,
|
||||||
mobile: 2,
|
mobile: 2,
|
||||||
tablet: 4,
|
tablet: 4,
|
||||||
desktop: 6,
|
desktop: 6,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Padding(
|
return GridView.builder(
|
||||||
padding: const EdgeInsets.all(12.0),
|
padding: const EdgeInsets.all(16),
|
||||||
child: GridView.builder(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
crossAxisCount: columns,
|
||||||
crossAxisCount: gridColumns,
|
mainAxisSpacing: 16,
|
||||||
crossAxisSpacing: 12,
|
crossAxisSpacing: 16,
|
||||||
mainAxisSpacing: 12,
|
childAspectRatio: 1.4, // Card ratio
|
||||||
childAspectRatio: 1.6,
|
|
||||||
),
|
|
||||||
itemCount: categories.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final category = categories[index];
|
|
||||||
final channelCount = groupedChannels[category]!.length;
|
|
||||||
|
|
||||||
return _CategoryBox(
|
|
||||||
categoryName: category,
|
|
||||||
channelCount: channelCount,
|
|
||||||
onTap: () {
|
|
||||||
setState(() {
|
|
||||||
_selectedCategory = category;
|
|
||||||
_currentPage = 0;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
itemCount: channels.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final channel = channels[index];
|
||||||
|
return ChannelCard(
|
||||||
|
streamId: channel.streamId, // Now required
|
||||||
|
name: channel.name,
|
||||||
|
iconUrl: _getProxiedIconUrl(channel.streamIcon),
|
||||||
|
currentProgram: null, // ShortEPG requires async fetch
|
||||||
|
onTap: () => _playChannel(channel),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build channels list for selected category
|
Widget _buildListView(List<Channel> channels) {
|
||||||
Widget _buildChannelsList(List<Channel> channels) {
|
return ListView.separated(
|
||||||
final totalPages = (channels.length / _itemsPerPage).ceil();
|
padding: const EdgeInsets.all(16),
|
||||||
final startIndex = _currentPage * _itemsPerPage;
|
itemCount: channels.length,
|
||||||
final endIndex = (startIndex + _itemsPerPage > channels.length)
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||||
? channels.length
|
itemBuilder: (context, index) {
|
||||||
: startIndex + _itemsPerPage;
|
final channel = channels[index];
|
||||||
final paginatedChannels = channels.sublist(startIndex, endIndex);
|
return SizedBox(
|
||||||
|
height: 80, // Fixed height for list item
|
||||||
return Column(
|
child: ChannelCard(
|
||||||
children: [
|
streamId: channel.streamId,
|
||||||
// Header with back button
|
name: channel.name,
|
||||||
Container(
|
iconUrl: _getProxiedIconUrl(channel.streamIcon),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
height: 80,
|
||||||
decoration: BoxDecoration(
|
width: double.infinity, // Full width
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
onTap: () => _playChannel(channel),
|
||||||
border: Border(
|
|
||||||
bottom: BorderSide(
|
|
||||||
color: Theme.of(context).dividerColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Row(
|
);
|
||||||
children: [
|
},
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.arrow_back),
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
_selectedCategory = null;
|
|
||||||
_currentPage = 0;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
_selectedCategory ?? '',
|
|
||||||
style: GoogleFonts.roboto(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'${channels.length} channels',
|
|
||||||
style: GoogleFonts.roboto(
|
|
||||||
fontSize: 14,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Pagination controls
|
|
||||||
if (totalPages > 1)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.chevron_left),
|
|
||||||
onPressed: _currentPage > 0
|
|
||||||
? () => setState(() => _currentPage--)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'${_currentPage + 1} / $totalPages',
|
|
||||||
style: GoogleFonts.roboto(
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.chevron_right),
|
|
||||||
onPressed: _currentPage < totalPages - 1
|
|
||||||
? () => setState(() => _currentPage++)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Channels list
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: paginatedChannels.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final channel = paginatedChannels[index];
|
|
||||||
return _buildChannelTile(context, channel);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildChannelTile(BuildContext context, Channel channel) {
|
String? _getProxiedIconUrl(String originalUrl) {
|
||||||
// Accept both HTTP and HTTPS icons - use proxy for HTTP
|
if (originalUrl.isEmpty) return null;
|
||||||
final bool hasIcon = channel.streamIcon.isNotEmpty;
|
if (originalUrl.startsWith('http://')) {
|
||||||
String iconUrl = channel.streamIcon;
|
return '/api/xtream/$originalUrl';
|
||||||
|
|
||||||
// Proxy HTTP images through our server to avoid Mixed Content blocking
|
|
||||||
if (hasIcon && channel.streamIcon.startsWith('http://')) {
|
|
||||||
iconUrl = '/api/xtream/${channel.streamIcon}';
|
|
||||||
}
|
}
|
||||||
|
return originalUrl;
|
||||||
|
}
|
||||||
|
|
||||||
final Widget placeholder = Container(
|
void _playChannel(Channel channel) {
|
||||||
width: 48,
|
Navigator.of(context).push(
|
||||||
height: 48,
|
MaterialPageRoute(
|
||||||
decoration: BoxDecoration(
|
builder: (context) => PlayerScreen(
|
||||||
color: Colors.grey.shade800,
|
streamId: channel.streamId,
|
||||||
borderRadius: BorderRadius.circular(8),
|
title: channel.name,
|
||||||
),
|
playlist: widget.playlist,
|
||||||
child: const Icon(Icons.tv, color: Colors.white54),
|
streamType: StreamType.live,
|
||||||
);
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => PlayerScreen(
|
|
||||||
streamId: channel.streamId,
|
|
||||||
title: channel.name,
|
|
||||||
playlist: widget.playlist,
|
|
||||||
streamType: StreamType.live,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
// Channel icon
|
|
||||||
hasIcon
|
|
||||||
? ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: iconUrl,
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
placeholder: (context, url) => placeholder,
|
|
||||||
errorWidget: (context, url, error) => placeholder,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: placeholder,
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
|
|
||||||
// Channel info + EPG
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
channel.name,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: GoogleFonts.roboto(
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
// EPG info
|
|
||||||
EPGWidget(
|
|
||||||
channelId: channel.streamId,
|
|
||||||
playlist: widget.playlist,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Play button
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Icons.play_arrow,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Category box card widget
|
|
||||||
class _CategoryBox extends StatelessWidget {
|
|
||||||
final String categoryName;
|
|
||||||
final int channelCount;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
const _CategoryBox({
|
|
||||||
required this.categoryName,
|
|
||||||
required this.channelCount,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [
|
|
||||||
Theme.of(context).colorScheme.primary.withOpacity(0.15),
|
|
||||||
Theme.of(context).colorScheme.secondary.withOpacity(0.08),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
border: Border.all(
|
|
||||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.25),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.live_tv,
|
|
||||||
size: 16,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'$channelCount',
|
|
||||||
style: GoogleFonts.roboto(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Text(
|
|
||||||
categoryName,
|
|
||||||
style: GoogleFonts.roboto(
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
|||||||
required bool isSelected,
|
required bool isSelected,
|
||||||
required VoidCallback onTap,
|
required VoidCallback onTap,
|
||||||
}) {
|
}) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
@@ -210,11 +211,11 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
|||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? AppColors.primary.withOpacity(0.1)
|
? colorScheme.primary.withOpacity(0.1)
|
||||||
: (isDark ? AppColors.surfaceVariantDark : AppColors.surfaceVariantLight),
|
: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isSelected ? AppColors.primary : Colors.transparent,
|
color: isSelected ? colorScheme.primary : Colors.transparent,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -223,8 +224,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
|||||||
Icon(
|
Icon(
|
||||||
icon,
|
icon,
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? AppColors.primary
|
? colorScheme.primary
|
||||||
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight),
|
: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -236,15 +237,15 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
|||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? AppColors.primary
|
? colorScheme.primary
|
||||||
: (isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight),
|
: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -253,7 +254,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
|||||||
if (isSelected)
|
if (isSelected)
|
||||||
Icon(
|
Icon(
|
||||||
Icons.check_circle,
|
Icons.check_circle,
|
||||||
color: AppColors.primary,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -261,6 +262,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Widget _buildFiltersTab(BuildContext context, dynamic currentUser, IptvSettings settings) {
|
Widget _buildFiltersTab(BuildContext context, dynamic currentUser, IptvSettings settings) {
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|||||||
+82
-80
@@ -1,96 +1,98 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
||||||
<head>
|
<meta charset="UTF-8">
|
||||||
<meta charset="UTF-8">
|
<title>IPTV Player</title>
|
||||||
<title>IPTV Player</title>
|
<!-- Add HLS.js and mpegts.js dependencies -->
|
||||||
<style>
|
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.4.12/dist/hls.min.js"></script>
|
||||||
* {
|
<script src="https://cdn.jsdelivr.net/npm/mpegts.js@1.7.3/dist/mpegts.min.js"></script>
|
||||||
margin: 0;
|
<style>
|
||||||
padding: 0;
|
* {
|
||||||
box-sizing: border-box;
|
margin: 0;
|
||||||
}
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: #000;
|
background: #000;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
#player-container {
|
#player-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
#video {
|
#video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #000;
|
background: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
#error {
|
#error {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#error-icon {
|
#error-icon {
|
||||||
font-size: 64px;
|
font-size: 64px;
|
||||||
color: #f44336;
|
color: #f44336;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#loading {
|
#loading {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border: 4px solid #fff;
|
border: 4px solid #fff;
|
||||||
border-top-color: transparent;
|
border-top-color: transparent;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
margin: 0 auto 16px;
|
margin: 0 auto 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#debug {
|
#debug {
|
||||||
display: none;
|
display: none;
|
||||||
/* Hidden in production */
|
/* Hidden in production */
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 10px;
|
bottom: 10px;
|
||||||
left: 10px;
|
left: 10px;
|
||||||
color: #0f0;
|
color: #0f0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: rgba(0, 0, 0, 0.7);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
max-width: 80%;
|
max-width: 80%;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Reference in new issue
Block a user