mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-11 17:29:26 +02:00
Merge pull request #53 from R0m1k3/antigravity
feat: Implement server with API routing, static file serving, Xtream …
This commit is contained in:
commit
5293db2885
9 files changed
+1203
-920
No files matched your search
@@ -431,6 +431,10 @@ Handler _createStreamHandler() {
|
|||||||
'-reconnect_on_http_error', '4xx,5xx',
|
'-reconnect_on_http_error', '4xx,5xx',
|
||||||
'-rw_timeout', '15000000', // 15s timeout
|
'-rw_timeout', '15000000', // 15s timeout
|
||||||
|
|
||||||
|
// Probing settings to ensure stream detection
|
||||||
|
'-analyzeduration', '20000000', // 20s
|
||||||
|
'-probesize', '10000000', // 10MB
|
||||||
|
|
||||||
'-i', iptvUrl,
|
'-i', iptvUrl,
|
||||||
|
|
||||||
// Output format: MPEG-TS for streaming (compatible with mpegts.js)
|
// Output format: MPEG-TS for streaming (compatible with mpegts.js)
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ class AppColors {
|
|||||||
/// Pure black for that infinite depth look (OLED friendly)
|
/// Pure black for that infinite depth look (OLED friendly)
|
||||||
static const Color background = Color(0xFF000000);
|
static const Color background = Color(0xFF000000);
|
||||||
|
|
||||||
/// Very deep grey for surfaces that need to be distinct but subtle
|
/// Very deep grey for surfaces that need to be distinct but subtle (Apple Dark Grey)
|
||||||
static const Color surface = Color(0xFF121212);
|
static const Color surface = Color(0xFF1C1C1E);
|
||||||
|
|
||||||
/// Focused element background (often white in tvOS for text, or bright accent)
|
/// Focused element background (often white in tvOS for text, or bright accent)
|
||||||
static const Color focusColor = Color(0xFFFFFFFF);
|
static const Color focusColor = Color(0xFFFFFFFF);
|
||||||
|
|||||||
@@ -17,16 +17,17 @@ class AppTheme {
|
|||||||
static const double spacing32 = 32.0;
|
static const double spacing32 = 32.0;
|
||||||
static const double spacing48 = 48.0; // Larger spacing for TV feel
|
static const double spacing48 = 48.0; // Larger spacing for TV feel
|
||||||
|
|
||||||
|
// ============ RADIUS ============
|
||||||
// ============ RADIUS ============
|
// ============ RADIUS ============
|
||||||
static const double radiusSm = 8.0;
|
static const double radiusSm = 8.0;
|
||||||
static const double radiusMd = 12.0;
|
static const double radiusMd = 12.0;
|
||||||
static const double radiusLg = 16.0;
|
static const double radiusLg = 20.0;
|
||||||
static const double radiusXl = 24.0;
|
static const double radiusXl = 32.0;
|
||||||
|
|
||||||
// ============ ANIMATION ============
|
// ============ ANIMATION ============
|
||||||
static const Duration durationFast = Duration(milliseconds: 200);
|
static const Duration durationFast = Duration(milliseconds: 200);
|
||||||
static const Duration durationNormal = Duration(milliseconds: 300);
|
static const Duration durationNormal = Duration(milliseconds: 300);
|
||||||
static const Curve curveDefault = Curves.easeOutCubic;
|
static const Curve curveDefault = Curves.fastOutSlowIn; // Apple-like snappy curve
|
||||||
|
|
||||||
// ============ DARK THEME (TV Main) ============
|
// ============ DARK THEME (TV Main) ============
|
||||||
static ThemeData get darkTheme {
|
static ThemeData get darkTheme {
|
||||||
@@ -36,114 +37,95 @@ class AppTheme {
|
|||||||
colorScheme: AppColors.darkColorScheme,
|
colorScheme: AppColors.darkColorScheme,
|
||||||
scaffoldBackgroundColor: AppColors.background,
|
scaffoldBackgroundColor: AppColors.background,
|
||||||
|
|
||||||
// AppBar: Totally transparent, content floats below
|
// Typography: San Francisco style (using Inter as proxy)
|
||||||
|
textTheme: TextTheme(
|
||||||
|
displayLarge: GoogleFonts.inter(fontSize: 56, fontWeight: FontWeight.w700, letterSpacing: -1.0, color: AppColors.textPrimary),
|
||||||
|
displayMedium: GoogleFonts.inter(fontSize: 48, fontWeight: FontWeight.w700, letterSpacing: -0.5, color: AppColors.textPrimary),
|
||||||
|
displaySmall: GoogleFonts.inter(fontSize: 32, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||||||
|
headlineMedium: GoogleFonts.inter(fontSize: 24, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||||||
|
titleLarge: GoogleFonts.inter(fontSize: 20, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||||||
|
titleMedium: GoogleFonts.inter(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||||||
|
bodyLarge: GoogleFonts.inter(fontSize: 16, fontWeight: FontWeight.w400, color: AppColors.textSecondary),
|
||||||
|
bodyMedium: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w400, color: AppColors.textSecondary),
|
||||||
|
labelLarge: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||||||
|
),
|
||||||
|
|
||||||
|
// AppBar: Transparent / Glass
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: AppBarTheme(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
surfaceTintColor: Colors.transparent,
|
foregroundColor: AppColors.textPrimary,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
iconTheme: const IconThemeData(color: AppColors.textSecondary),
|
scrolledUnderElevation: 0,
|
||||||
titleTextStyle: GoogleFonts.inter(
|
titleTextStyle: GoogleFonts.inter(
|
||||||
fontSize: 16,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textPrimary,
|
||||||
letterSpacing: 0.5,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// NavigationBar: Minimal, often just icons or text
|
// Inputs: Dark, minimal, no borders unless focused
|
||||||
navigationBarTheme: NavigationBarThemeData(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
indicatorColor: Colors.transparent,
|
|
||||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
|
||||||
height: 80,
|
|
||||||
iconTheme: WidgetStateProperty.resolveWith((states) {
|
|
||||||
final isSelected = states.contains(WidgetState.selected);
|
|
||||||
// Selected: White and slightly larger/glowing
|
|
||||||
// Unselected: Grey and smaller
|
|
||||||
return IconThemeData(
|
|
||||||
color: isSelected ? AppColors.focusColor : AppColors.textSecondary,
|
|
||||||
size: isSelected ? 28 : 24,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
|
||||||
final isSelected = states.contains(WidgetState.selected);
|
|
||||||
return GoogleFonts.inter(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
|
||||||
color: isSelected ? AppColors.focusColor : AppColors.textSecondary,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Cards: No background by default, they pop on focus
|
|
||||||
cardTheme: CardThemeData(
|
|
||||||
color: Colors.transparent, // Background comes from container in widget
|
|
||||||
elevation: 0,
|
|
||||||
margin: EdgeInsets.zero,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(radiusMd),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Inputs: Minimal, dark pills
|
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: AppColors.surface,
|
fillColor: AppColors.surface,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: spacing24, vertical: spacing16),
|
contentPadding: const EdgeInsets.symmetric(horizontal: spacing24, vertical: spacing16),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(radiusFull), // Pill shape
|
borderRadius: BorderRadius.circular(radiusMd),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(radiusFull),
|
borderRadius: BorderRadius.circular(radiusMd),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(radiusFull),
|
borderRadius: BorderRadius.circular(radiusMd),
|
||||||
borderSide: const BorderSide(color: AppColors.focusColor, width: 2),
|
borderSide: const BorderSide(color: AppColors.focusColor, width: 2),
|
||||||
),
|
),
|
||||||
hintStyle: GoogleFonts.inter(color: AppColors.textTertiary),
|
hintStyle: GoogleFonts.inter(color: AppColors.textTertiary),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Buttons: White pill buttons usually
|
// Buttons: White pill or minimal text
|
||||||
filledButtonTheme: FilledButtonThemeData(
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: AppColors.focusColor, // White buttons
|
backgroundColor: AppColors.focusColor, // White buttons
|
||||||
foregroundColor: Colors.black, // Black text
|
foregroundColor: Colors.black, // Black text
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
borderRadius: BorderRadius.circular(radiusFull),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusMd)),
|
||||||
),
|
textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20),
|
|
||||||
textStyle: GoogleFonts.inter(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
).copyWith(
|
).copyWith(
|
||||||
overlayColor: WidgetStateProperty.all(Colors.black.withOpacity(0.1)),
|
overlayColor: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.hovered)) return Colors.black.withOpacity(0.1);
|
||||||
|
if (states.contains(WidgetState.pressed)) return Colors.black.withOpacity(0.2);
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Cards: Transparent by default (content defines look)
|
||||||
|
cardTheme: const CardTheme(
|
||||||
|
color: AppColors.surface,
|
||||||
|
elevation: 0,
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(radiusMd))),
|
||||||
|
),
|
||||||
|
|
||||||
textTheme: TextTheme(
|
// Dialogs: Glass/Dark
|
||||||
displayLarge: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.bold),
|
dialogTheme: DialogTheme(
|
||||||
titleLarge: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.w600),
|
backgroundColor: const Color(0xFF1C1C1E), // Apple Dark Grey
|
||||||
bodyLarge: GoogleFonts.inter(color: AppColors.textSecondary, fontSize: 16),
|
elevation: 24,
|
||||||
bodyMedium: GoogleFonts.inter(color: AppColors.textSecondary, fontSize: 14),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusLg)),
|
||||||
|
titleTextStyle: GoogleFonts.inter(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.textPrimary),
|
||||||
|
contentTextStyle: GoogleFonts.inter(fontSize: 16, color: AppColors.textSecondary),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static const double radiusFull = 999.0;
|
static const double radiusFull = 999.0;
|
||||||
|
|
||||||
// Light theme stub (TV interfaces rarely use light mode, but good for completeness)
|
// Light theme stub
|
||||||
static ThemeData get lightTheme => ThemeData.light(useMaterial3: true).copyWith(
|
static ThemeData get lightTheme => ThemeData.light(useMaterial3: true).copyWith(
|
||||||
colorScheme: AppColors.lightColorScheme,
|
colorScheme: AppColors.lightColorScheme,
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme),
|
||||||
filled: true,
|
|
||||||
fillColor: const Color(0xFFF2F2F7),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(radiusFull),
|
|
||||||
borderSide: BorderSide.none)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import '../../theme/app_colors.dart';
|
||||||
|
import '../../theme/app_theme.dart';
|
||||||
|
|
||||||
|
class HeroCarousel extends StatefulWidget {
|
||||||
|
final List<HeroItem> items;
|
||||||
|
final Function(HeroItem) onTap;
|
||||||
|
|
||||||
|
const HeroCarousel({
|
||||||
|
super.key,
|
||||||
|
required this.items,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HeroCarousel> createState() => _HeroCarouselState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HeroCarouselState extends State<HeroCarousel> {
|
||||||
|
late PageController _pageController;
|
||||||
|
int _currentPage = 0;
|
||||||
|
Timer? _timer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_pageController = PageController(viewportFraction: 0.9);
|
||||||
|
_startAutoPlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pageController.dispose();
|
||||||
|
_stopAutoPlay();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startAutoPlay() {
|
||||||
|
_timer = Timer.periodic(const Duration(seconds: 8), (timer) {
|
||||||
|
if (_currentPage < widget.items.length - 1) {
|
||||||
|
_currentPage++;
|
||||||
|
} else {
|
||||||
|
_currentPage = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_pageController.hasClients) {
|
||||||
|
_pageController.animateToPage(
|
||||||
|
_currentPage,
|
||||||
|
duration: const Duration(milliseconds: 800),
|
||||||
|
curve: Curves.fastOutSlowIn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopAutoPlay() {
|
||||||
|
_timer?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.items.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: 400,
|
||||||
|
child: PageView.builder(
|
||||||
|
controller: _pageController,
|
||||||
|
onPageChanged: (index) {
|
||||||
|
setState(() => _currentPage = index);
|
||||||
|
},
|
||||||
|
itemCount: widget.items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = widget.items[index];
|
||||||
|
final isActive = index == _currentPage;
|
||||||
|
|
||||||
|
return AnimatedScale(
|
||||||
|
duration: const Duration(milliseconds: 500),
|
||||||
|
scale: isActive ? 1.0 : 0.9,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 500),
|
||||||
|
opacity: isActive ? 1.0 : 0.5,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => widget.onTap(item),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||||
|
boxShadow: [
|
||||||
|
if (isActive)
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.5),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
// Background Image
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: item.imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (context, url) => Container(color: AppColors.surfaceVariant),
|
||||||
|
errorWidget: (context, url, error) => Container(
|
||||||
|
color: AppColors.surfaceVariant,
|
||||||
|
child: const Icon(Icons.movie, size: 64, color: AppColors.textTertiary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Gradient Overlay
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.transparent,
|
||||||
|
Colors.black.withOpacity(0.2),
|
||||||
|
Colors.black.withOpacity(0.9),
|
||||||
|
],
|
||||||
|
stops: const [0.0, 0.5, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Content
|
||||||
|
Positioned(
|
||||||
|
bottom: 32,
|
||||||
|
left: 32,
|
||||||
|
right: 32,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.title,
|
||||||
|
style: GoogleFonts.outfit(
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (item.subtitle != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
item.subtitle!,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.white.withOpacity(0.8),
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_HeroButton(
|
||||||
|
icon: Icons.play_arrow,
|
||||||
|
label: 'Play',
|
||||||
|
onPressed: () => widget.onTap(item),
|
||||||
|
isPrimary: true,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
if (item.onMoreInfo != null)
|
||||||
|
_HeroButton(
|
||||||
|
icon: Icons.info_outline,
|
||||||
|
label: 'More Info',
|
||||||
|
onPressed: item.onMoreInfo!,
|
||||||
|
isPrimary: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HeroButton extends StatefulWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
final bool isPrimary;
|
||||||
|
|
||||||
|
const _HeroButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onPressed,
|
||||||
|
required this.isPrimary,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_HeroButton> createState() => _HeroButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HeroButtonState extends State<_HeroButton> {
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _isHovered = true),
|
||||||
|
onExit: (_) => setState(() => _isHovered = false),
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: widget.onPressed,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: widget.isPrimary
|
||||||
|
? (_isHovered ? Colors.white : Colors.white.withOpacity(0.9))
|
||||||
|
: (_isHovered ? Colors.white.withOpacity(0.2) : Colors.white.withOpacity(0.1)),
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
widget.icon,
|
||||||
|
color: widget.isPrimary ? Colors.black : Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
widget.label,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: widget.isPrimary ? Colors.black : Colors.white,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HeroItem {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String imageUrl;
|
||||||
|
final String? subtitle;
|
||||||
|
final VoidCallback? onMoreInfo;
|
||||||
|
|
||||||
|
HeroItem({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.imageUrl,
|
||||||
|
this.subtitle,
|
||||||
|
this.onMoreInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -306,3 +306,194 @@ class CategoryChip extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apple TV style Media Card (Vertical Poster)
|
||||||
|
class MediaCard extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final String? imageUrl;
|
||||||
|
final String? subtitle;
|
||||||
|
final String? rating;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final VoidCallback? onLongPress;
|
||||||
|
final bool isWatched;
|
||||||
|
final IconData placeholderIcon;
|
||||||
|
|
||||||
|
const MediaCard({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.imageUrl,
|
||||||
|
this.subtitle,
|
||||||
|
this.rating,
|
||||||
|
this.onTap,
|
||||||
|
this.onLongPress,
|
||||||
|
this.isWatched = false,
|
||||||
|
this.placeholderIcon = Icons.movie,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MediaCard> createState() => _MediaCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MediaCardState extends State<MediaCard>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _isHovered = true),
|
||||||
|
onExit: (_) => setState(() => _isHovered = false),
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: widget.onTap,
|
||||||
|
onLongPress: widget.onLongPress,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Poster Image
|
||||||
|
Expanded(
|
||||||
|
child: AnimatedScale(
|
||||||
|
scale: _isHovered ? 1.05 : 1.0,
|
||||||
|
duration: AppTheme.durationFast,
|
||||||
|
curve: AppTheme.curveDefault,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: AppTheme.durationFast,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||||
|
color: AppColors.surface,
|
||||||
|
border: Border.all(
|
||||||
|
color: _isHovered ? AppColors.focusColor : Colors.transparent,
|
||||||
|
width: _isHovered ? 3 : 0,
|
||||||
|
),
|
||||||
|
boxShadow: _isHovered
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.5),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.2),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd - (_isHovered ? 2 : 0)),
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (widget.imageUrl != null)
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: widget.imageUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (context, url) => Container(
|
||||||
|
color: AppColors.surface,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(widget.placeholderIcon, color: AppColors.textTertiary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) => Container(
|
||||||
|
color: AppColors.surface,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(widget.placeholderIcon, color: AppColors.textTertiary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(
|
||||||
|
color: AppColors.surface,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(widget.placeholderIcon, color: AppColors.textTertiary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Rating Badge (Top Left)
|
||||||
|
if (widget.rating != null && widget.rating!.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
left: 8,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.7),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.star, size: 10, color: Color(0xFFFFD700)),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
widget.rating!,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Watched Indicator (Top Right)
|
||||||
|
if (widget.isWatched)
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.success,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.check, size: 12, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Title & Subtitle (Below)
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.title,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: _isHovered ? AppColors.focusColor : AppColors.textSecondary,
|
||||||
|
fontWeight: _isHovered ? FontWeight.w600 : FontWeight.w500,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.subtitle != null && _isHovered) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
widget.subtitle!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
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:google_fonts/google_fonts.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/services/playlist_api_service.dart';
|
import '../../../core/services/playlist_api_service.dart';
|
||||||
import '../../../core/services/user_api_service.dart';
|
import '../../../core/services/user_api_service.dart';
|
||||||
@@ -42,40 +43,100 @@ class _AdminPanelState extends ConsumerState<AdminPanel>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
backgroundColor: AppColors.background,
|
||||||
title: Row(
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
// Custom Glass Header
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(6),
|
padding: const EdgeInsets.all(24),
|
||||||
decoration: BoxDecoration(
|
child: Row(
|
||||||
color: AppColors.primary.withOpacity(0.2),
|
children: [
|
||||||
borderRadius: BorderRadius.circular(8),
|
GlassCard(
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
showBorder: false,
|
||||||
|
onTap: () => context.go('/playlists'),
|
||||||
|
child: const Icon(Icons.arrow_back, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Text(
|
||||||
|
'Administration',
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
// Custom Tab Bar Indicator
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildTabButton(0, 'Playlists', Icons.playlist_play),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
_buildTabButton(1, 'Users', Icons.people),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Content
|
||||||
|
Expanded(
|
||||||
|
child: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
physics: const NeverScrollableScrollPhysics(), // Custom tabs handle ref
|
||||||
|
children: const [
|
||||||
|
_PlaylistsTab(),
|
||||||
|
_UsersTab(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.admin_panel_settings, size: 20),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
|
||||||
const Text('Administration'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
leading: IconButton(
|
|
||||||
icon: const Icon(Icons.arrow_back),
|
|
||||||
onPressed: () => context.go('/playlists'),
|
|
||||||
),
|
|
||||||
bottom: TabBar(
|
|
||||||
controller: _tabController,
|
|
||||||
tabs: const [
|
|
||||||
Tab(icon: Icon(Icons.playlist_play), text: 'Playlists'),
|
|
||||||
Tab(icon: Icon(Icons.people), text: 'Utilisateurs'),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: TabBarView(
|
);
|
||||||
controller: _tabController,
|
}
|
||||||
children: const [
|
|
||||||
_PlaylistsTab(),
|
Widget _buildTabButton(int index, String label, IconData icon) {
|
||||||
_UsersTab(),
|
return AnimatedBuilder(
|
||||||
],
|
animation: _tabController.animation!,
|
||||||
),
|
builder: (context, child) {
|
||||||
|
final isSelected = _tabController.index == index;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _tabController.animateTo(index),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: AppTheme.durationFast,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? AppColors.focusColor : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 18,
|
||||||
|
color: isSelected ? Colors.black : AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
color: isSelected ? Colors.black : AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,80 +152,89 @@ class _PlaylistsTab extends ConsumerWidget {
|
|||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
|
||||||
child: GradientButton(
|
child: Row(
|
||||||
label: 'Ajouter une playlist',
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
icon: Icons.add,
|
children: [
|
||||||
onPressed: () => _showPlaylistDialog(context, ref),
|
Text('${playlistsAsync.asData?.value.length ?? 0} Playlists', style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
GradientButton(
|
||||||
|
label: 'Add Playlist',
|
||||||
|
icon: Icons.add,
|
||||||
|
onPressed: () => _showPlaylistDialog(context, ref),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: playlistsAsync.when(
|
child: playlistsAsync.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, stack) => Center(
|
error: (error, stack) => Center(child: Text('Error: $error', style: const TextStyle(color: AppColors.error))),
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.error_outline, size: 48, color: AppColors.error),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text('Erreur: $error'),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
OutlinedButton(
|
|
||||||
onPressed: () => ref.refresh(playlistsProvider),
|
|
||||||
child: const Text('Réessayer'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
data: (playlists) {
|
data: (playlists) {
|
||||||
if (playlists.isEmpty) {
|
if (playlists.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.playlist_remove, size: 64, color: AppColors.textSecondary),
|
Icon(Icons.playlist_remove, size: 64, color: AppColors.textSecondary.withOpacity(0.5)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text('No playlists configured', style: Theme.of(context).textTheme.titleMedium),
|
||||||
'Aucune playlist',
|
const SizedBox(height: 8),
|
||||||
style: TextStyle(fontSize: 18, color: AppColors.textSecondary),
|
TextButton(
|
||||||
|
onPressed: () => _showPlaylistDialog(context, ref),
|
||||||
|
child: const Text('Add your first playlist'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||||
itemCount: playlists.length,
|
itemCount: playlists.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final playlist = playlists[index];
|
final playlist = playlists[index];
|
||||||
return Card(
|
return GlassCard(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.all(20),
|
||||||
child: ListTile(
|
child: Row(
|
||||||
leading: Container(
|
children: [
|
||||||
width: 40,
|
Container(
|
||||||
height: 40,
|
width: 48,
|
||||||
decoration: BoxDecoration(
|
height: 48,
|
||||||
gradient: AppColors.primaryGradient,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(10),
|
color: AppColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.playlist_play, color: AppColors.textPrimary),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.playlist_play, color: Colors.black, size: 20),
|
const SizedBox(width: 16),
|
||||||
),
|
Expanded(
|
||||||
title: Text(playlist.name),
|
child: Column(
|
||||||
subtitle: Text(playlist.dns, style: const TextStyle(fontSize: 11)),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
trailing: Row(
|
children: [
|
||||||
mainAxisSize: MainAxisSize.min,
|
Text(playlist.name, style: Theme.of(context).textTheme.titleMedium),
|
||||||
children: [
|
const SizedBox(height: 4),
|
||||||
IconButton(
|
Text(playlist.dns, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: AppColors.textTertiary)),
|
||||||
icon: const Icon(Icons.edit_outlined),
|
],
|
||||||
onPressed: () => _showPlaylistDialog(context, ref, playlist),
|
|
||||||
),
|
),
|
||||||
IconButton(
|
),
|
||||||
icon: Icon(Icons.delete_outline, color: AppColors.error),
|
Row(
|
||||||
onPressed: () => _deletePlaylist(context, ref, playlist),
|
children: [
|
||||||
),
|
IconButton(
|
||||||
],
|
icon: const Icon(Icons.edit_outlined, size: 20),
|
||||||
),
|
color: AppColors.textSecondary,
|
||||||
|
onPressed: () => _showPlaylistDialog(context, ref, playlist),
|
||||||
|
tooltip: 'Edit',
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline, size: 20),
|
||||||
|
color: AppColors.error,
|
||||||
|
onPressed: () => _deletePlaylist(context, ref, playlist),
|
||||||
|
tooltip: 'Delete',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -263,82 +333,102 @@ class _UsersTab extends ConsumerWidget {
|
|||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
|
||||||
child: GradientButton(
|
child: Row(
|
||||||
label: 'Créer un utilisateur',
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
icon: Icons.person_add,
|
children: [
|
||||||
onPressed: () => _showCreateUserDialog(context, ref),
|
Text('${usersAsync.asData?.value.length ?? 0} Users', style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
GradientButton(
|
||||||
|
label: 'Create User',
|
||||||
|
icon: Icons.person_add,
|
||||||
|
onPressed: () => _showCreateUserDialog(context, ref),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: usersAsync.when(
|
child: usersAsync.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, stack) => Center(
|
error: (error, stack) => Center(child: Text('Error: $error', style: const TextStyle(color: AppColors.error))),
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.error_outline, size: 48, color: AppColors.error),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text('Erreur: $error'),
|
|
||||||
OutlinedButton(
|
|
||||||
onPressed: () => ref.refresh(usersProvider),
|
|
||||||
child: const Text('Réessayer'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
data: (users) {
|
data: (users) {
|
||||||
if (users.isEmpty) {
|
if (users.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.people_outline, size: 64, color: AppColors.textSecondary),
|
Icon(Icons.people_outline, size: 64, color: AppColors.textSecondary.withOpacity(0.5)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text('Aucun utilisateur', style: TextStyle(fontSize: 18, color: AppColors.textSecondary)),
|
Text('No users found', style: Theme.of(context).textTheme.titleMedium),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||||
itemCount: users.length,
|
itemCount: users.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final user = users[index];
|
final user = users[index];
|
||||||
return Card(
|
return GlassCard(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.all(20),
|
||||||
child: ListTile(
|
child: Row(
|
||||||
leading: CircleAvatar(
|
children: [
|
||||||
backgroundColor: user.isAdmin ? AppColors.primary.withOpacity(0.2) : AppColors.surface.withOpacity(0.5),
|
Container(
|
||||||
child: Icon(user.isAdmin ? Icons.admin_panel_settings : Icons.person, color: user.isAdmin ? AppColors.primary : AppColors.textSecondary),
|
width: 48,
|
||||||
),
|
height: 48,
|
||||||
title: Row(
|
decoration: BoxDecoration(
|
||||||
children: [
|
color: user.isAdmin ? AppColors.focusColor : AppColors.surface,
|
||||||
Text(user.username),
|
shape: BoxShape.circle,
|
||||||
if (user.isAdmin) ...[
|
),
|
||||||
const SizedBox(width: 8),
|
child: Icon(
|
||||||
Container(
|
user.isAdmin ? Icons.admin_panel_settings : Icons.person,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
color: user.isAdmin ? Colors.black : AppColors.textPrimary,
|
||||||
decoration: BoxDecoration(color: AppColors.primary.withOpacity(0.2), borderRadius: BorderRadius.circular(12)),
|
size: 24
|
||||||
child: Text('Admin', style: TextStyle(fontSize: 10, color: AppColors.primary, fontWeight: FontWeight.w600)),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(user.username, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
if (user.isAdmin) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.focusColor.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
),
|
||||||
|
child: const Text('ADMIN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: AppColors.focusColor)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('ID: ${user.id}', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: AppColors.textTertiary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert, color: AppColors.textSecondary),
|
||||||
|
color: AppColors.surface,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
onSelected: (value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'password': _showChangePasswordDialog(context, ref, user); break;
|
||||||
|
case 'delete': _deleteUser(context, ref, user); break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(value: 'password', child: Row(children: [Icon(Icons.lock_reset, size: 18), SizedBox(width: 8), Text('Change Password')])),
|
||||||
|
const PopupMenuItem(value: 'delete', child: Row(children: [Icon(Icons.delete, color: AppColors.error, size: 18), SizedBox(width: 8), Text('Delete', style: TextStyle(color: AppColors.error))])),
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
subtitle: Text('ID: ${user.id}', style: const TextStyle(fontSize: 11)),
|
|
||||||
trailing: PopupMenuButton<String>(
|
|
||||||
onSelected: (value) {
|
|
||||||
switch (value) {
|
|
||||||
case 'password': _showChangePasswordDialog(context, ref, user); break;
|
|
||||||
case 'delete': _deleteUser(context, ref, user); break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
itemBuilder: (context) => [
|
|
||||||
const PopupMenuItem(value: 'password', child: ListTile(leading: Icon(Icons.lock_reset), title: Text('Changer mot de passe'), dense: true)),
|
|
||||||
PopupMenuItem(value: 'delete', child: ListTile(leading: Icon(Icons.delete, color: AppColors.error), title: Text('Supprimer', style: TextStyle(color: AppColors.error)), dense: true)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import '../../../core/models/playlist_config.dart';
|
|||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../core/utils/responsive_layout.dart';
|
import '../../../core/utils/responsive_layout.dart';
|
||||||
|
import '../../../core/widgets/components/ui_components.dart';
|
||||||
import 'channel_card.dart';
|
import 'channel_card.dart';
|
||||||
|
|
||||||
/// Live TV tab - Enhanced with Search, Favorites and Grid/List View
|
/// Live TV tab - Apple TV Style with Horizontal Category Filter
|
||||||
class LiveTVTab extends ConsumerStatefulWidget {
|
class LiveTVTab extends ConsumerStatefulWidget {
|
||||||
final PlaylistConfig playlist;
|
final PlaylistConfig playlist;
|
||||||
|
|
||||||
@@ -27,14 +28,15 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
|
|
||||||
// State
|
// State
|
||||||
String? _selectedCategory;
|
String? _selectedCategory;
|
||||||
int _currentPage = 0;
|
|
||||||
static const int _itemsPerPage = 100; // Increased for grid view
|
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
bool _isGridView = true;
|
bool _isGridView = true;
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
bool _showFavoritesOnly = false;
|
bool _showFavoritesOnly = false;
|
||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
|
|
||||||
|
// Scroll Controllers
|
||||||
|
final ScrollController _mainScrollController = ScrollController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -42,7 +44,6 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
_searchController.addListener(() {
|
_searchController.addListener(() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_searchQuery = _searchController.text.toLowerCase();
|
_searchQuery = _searchController.text.toLowerCase();
|
||||||
_currentPage = 0; // Reset page on search
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
|
_mainScrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,316 +64,170 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
|
|
||||||
final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
|
final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
|
||||||
final favorites = ref.watch(favoritesProvider);
|
final favorites = ref.watch(favoritesProvider);
|
||||||
|
|
||||||
return Scaffold( // Use scaffold for toolbar body structure
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
body: Column(
|
|
||||||
children: [
|
|
||||||
// Toolbar
|
|
||||||
_buildToolbar(),
|
|
||||||
|
|
||||||
// Content
|
|
||||||
Expanded(
|
|
||||||
child: channelsAsync.when(
|
|
||||||
data: (groupedChannels) {
|
|
||||||
// 1. Flatten for global search/filter if needed
|
|
||||||
final allChannels = groupedChannels.values.expand((element) => element).toList();
|
|
||||||
|
|
||||||
List<Channel> displayedChannels = [];
|
|
||||||
bool isGlobalMode = _searchQuery.isNotEmpty || _showFavoritesOnly;
|
|
||||||
|
|
||||||
if (isGlobalMode) {
|
|
||||||
// Global Filter Mode
|
|
||||||
displayedChannels = allChannels.where((channel) {
|
|
||||||
final matchesSearch = _searchQuery.isEmpty ||
|
|
||||||
channel.name.toLowerCase().contains(_searchQuery);
|
|
||||||
final matchesFavorite = !_showFavoritesOnly ||
|
|
||||||
favorites.contains(channel.streamId);
|
|
||||||
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')),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildToolbar() {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
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.surface.withOpacity(0.5),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
borderSide: BorderSide.none,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
|
|
||||||
// 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);
|
final settings = ref.watch(iptvSettingsProvider);
|
||||||
var categories = groupedChannels.keys.toList();
|
|
||||||
|
|
||||||
// Apply Settings Filter (Hidden categories)
|
|
||||||
if (settings.liveTvKeywords.isNotEmpty) {
|
|
||||||
categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList();
|
|
||||||
}
|
|
||||||
categories.sort();
|
|
||||||
|
|
||||||
final crossAxisCount = ResponsiveLayout.value(
|
return Scaffold(
|
||||||
context,
|
backgroundColor: Colors.transparent,
|
||||||
mobile: 2,
|
body: channelsAsync.when(
|
||||||
tablet: 3,
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
desktop: 5,
|
error: (e, s) => Center(child: Text('Error: $e')),
|
||||||
);
|
data: (groupedChannels) {
|
||||||
|
// Groups
|
||||||
|
var categories = groupedChannels.keys.toList();
|
||||||
|
if (settings.liveTvKeywords.isNotEmpty) {
|
||||||
|
categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList();
|
||||||
|
}
|
||||||
|
categories.sort();
|
||||||
|
|
||||||
|
// Filter Logic
|
||||||
|
List<Channel> displayedChannels = [];
|
||||||
|
|
||||||
|
if (_searchQuery.isNotEmpty) {
|
||||||
|
// Global Search
|
||||||
|
displayedChannels = groupedChannels.values.expand((l) => l)
|
||||||
|
.where((c) => c.name.toLowerCase().contains(_searchQuery))
|
||||||
|
.toList();
|
||||||
|
} else if (_showFavoritesOnly) {
|
||||||
|
// Favorites
|
||||||
|
displayedChannels = groupedChannels.values.expand((l) => l)
|
||||||
|
.where((c) => favorites.contains(c.streamId))
|
||||||
|
.toList();
|
||||||
|
} else if (_selectedCategory != null) {
|
||||||
|
// Selected Category
|
||||||
|
displayedChannels = groupedChannels[_selectedCategory] ?? [];
|
||||||
|
} else {
|
||||||
|
// Default: Show first category or All if practical, but "All" is too big.
|
||||||
|
// Apple TV pattern: Auto-select first category if nothing selected.
|
||||||
|
if (categories.isNotEmpty) {
|
||||||
|
// We don't want to trigger setState during build, so we just use the first category functionality
|
||||||
|
// But visualized as "All" might be confusing with map keys.
|
||||||
|
// Let's default to the first category in the list.
|
||||||
|
_selectedCategory ??= categories.first;
|
||||||
|
displayedChannels = groupedChannels[_selectedCategory] ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return GridView.builder(
|
return Column(
|
||||||
padding: const EdgeInsets.all(16),
|
children: [
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
// Minimalist Header
|
||||||
crossAxisCount: crossAxisCount,
|
Container(
|
||||||
mainAxisSpacing: 16,
|
padding: const EdgeInsets.only(left: 24, right: 24, top: 16, bottom: 8),
|
||||||
crossAxisSpacing: 16,
|
child: Row(
|
||||||
childAspectRatio: 1.5,
|
children: [
|
||||||
),
|
Text(
|
||||||
itemCount: categories.length,
|
'Live TV',
|
||||||
itemBuilder: (context, index) {
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
final category = categories[index];
|
),
|
||||||
final count = groupedChannels[category]!.length;
|
const Spacer(),
|
||||||
|
|
||||||
return InkWell(
|
// Search Pill
|
||||||
onTap: () => setState(() {
|
Container(
|
||||||
_selectedCategory = category;
|
width: 250,
|
||||||
_currentPage = 0;
|
height: 36,
|
||||||
}),
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
color: AppColors.surface,
|
||||||
child: Container(
|
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
|
||||||
decoration: BoxDecoration(
|
border: Border.all(color: AppColors.border),
|
||||||
color: AppColors.surface,
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
|
||||||
border: Border.all(color: AppColors.border),
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [
|
|
||||||
AppColors.surface.withOpacity(0.8),
|
|
||||||
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),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
Container(
|
child: Row(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
children: [
|
||||||
decoration: BoxDecoration(
|
const Icon(Icons.search, size: 18, color: AppColors.textSecondary),
|
||||||
color: AppColors.primary.withOpacity(0.2),
|
const SizedBox(width: 8),
|
||||||
borderRadius: BorderRadius.circular(4),
|
Expanded(
|
||||||
),
|
child: TextField(
|
||||||
child: Text(
|
controller: _searchController,
|
||||||
'$count channels',
|
style: const TextStyle(fontSize: 13, color: AppColors.textPrimary),
|
||||||
style: GoogleFonts.inter(
|
decoration: const InputDecoration(
|
||||||
fontSize: 12,
|
hintText: 'Search channels',
|
||||||
color: AppColors.primary,
|
border: InputBorder.none,
|
||||||
fontWeight: FontWeight.bold,
|
isDense: true,
|
||||||
|
contentPadding: EdgeInsets.only(bottom: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
if (_searchQuery.isNotEmpty)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _searchController.clear(),
|
||||||
|
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary)
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
|
// Favorites Toggle
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_showFavoritesOnly ? Icons.favorite : Icons.favorite_border,
|
||||||
|
color: _showFavoritesOnly ? AppColors.error : AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_showFavoritesOnly = !_showFavoritesOnly;
|
||||||
|
if (_showFavoritesOnly) _searchController.clear();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: 'Favorites Only',
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_isGridView ? Icons.view_list : Icons.grid_view,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
onPressed: () => setState(() => _isGridView = !_isGridView),
|
||||||
|
tooltip: 'Toggle View',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Categories Horizontal List (Only if not searching/favorites)
|
||||||
|
if (_searchQuery.isEmpty && !_showFavoritesOnly)
|
||||||
|
Container(
|
||||||
|
height: 48,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: categories.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final category = categories[index];
|
||||||
|
final isSelected = category == _selectedCategory;
|
||||||
|
return CategoryChip(
|
||||||
|
label: category,
|
||||||
|
isSelected: isSelected,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedCategory = category;
|
||||||
|
_mainScrollController.jumpTo(0); // Reset scroll
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildChannelsView(List<Channel> channels, bool isGlobalMode) {
|
const SizedBox(height: 8),
|
||||||
// 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(
|
// Channels Content
|
||||||
children: [
|
Expanded(
|
||||||
// Breadcrumb / Back Navigation
|
child: displayedChannels.isEmpty
|
||||||
if (!isGlobalMode && _selectedCategory != null)
|
? Center(
|
||||||
Container(
|
child: Text(
|
||||||
width: double.infinity,
|
_showFavoritesOnly ? 'No favorites' : 'No channels found',
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: AppColors.textSecondary),
|
||||||
color: AppColors.surface.withOpacity(0.5),
|
),
|
||||||
child: Row(
|
)
|
||||||
children: [
|
: _isGridView
|
||||||
IconButton(
|
? _buildGridView(displayedChannels)
|
||||||
icon: const Icon(Icons.arrow_back_ios, size: 16),
|
: _buildListView(displayedChannels),
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,21 +240,22 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
);
|
);
|
||||||
|
|
||||||
return GridView.builder(
|
return GridView.builder(
|
||||||
padding: const EdgeInsets.all(16),
|
controller: _mainScrollController,
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: columns,
|
crossAxisCount: columns,
|
||||||
mainAxisSpacing: 16,
|
mainAxisSpacing: 16,
|
||||||
crossAxisSpacing: 16,
|
crossAxisSpacing: 16,
|
||||||
childAspectRatio: 1.4, // Card ratio
|
childAspectRatio: 1.3, // Card ratio 4:3ish
|
||||||
),
|
),
|
||||||
itemCount: channels.length,
|
itemCount: channels.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final channel = channels[index];
|
final channel = channels[index];
|
||||||
return ChannelCard(
|
return ChannelCard(
|
||||||
streamId: channel.streamId, // Now required
|
streamId: channel.streamId,
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
iconUrl: _getProxiedIconUrl(channel.streamIcon),
|
iconUrl: _getProxiedIconUrl(channel.streamIcon),
|
||||||
currentProgram: null, // ShortEPG requires async fetch
|
currentProgram: null,
|
||||||
onTap: () => _playChannel(channel),
|
onTap: () => _playChannel(channel),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -407,19 +264,20 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
|
|
||||||
Widget _buildListView(List<Channel> channels) {
|
Widget _buildListView(List<Channel> channels) {
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.all(16),
|
controller: _mainScrollController,
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
||||||
itemCount: channels.length,
|
itemCount: channels.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final channel = channels[index];
|
final channel = channels[index];
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 80, // Fixed height for list item
|
height: 70,
|
||||||
child: ChannelCard(
|
child: ChannelCard(
|
||||||
streamId: channel.streamId,
|
streamId: channel.streamId,
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
iconUrl: _getProxiedIconUrl(channel.streamIcon),
|
iconUrl: _getProxiedIconUrl(channel.streamIcon),
|
||||||
height: 80,
|
height: 70,
|
||||||
width: double.infinity, // Full width
|
width: double.infinity,
|
||||||
onTap: () => _playChannel(channel),
|
onTap: () => _playChannel(channel),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
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 '../../../core/models/playlist_config.dart';
|
import '../../../core/models/playlist_config.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../core/utils/responsive_layout.dart';
|
||||||
|
import '../../../core/widgets/components/hero_carousel.dart';
|
||||||
|
import '../../../core/widgets/components/ui_components.dart';
|
||||||
|
import '../../favorites/providers/watch_history_provider.dart';
|
||||||
import '../models/xtream_models.dart';
|
import '../models/xtream_models.dart';
|
||||||
import '../providers/xtream_provider.dart';
|
import '../providers/xtream_provider.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
import '../providers/watch_history_provider.dart';
|
|
||||||
import '../screens/player_screen.dart';
|
import '../screens/player_screen.dart';
|
||||||
|
|
||||||
class MoviesTab extends ConsumerStatefulWidget {
|
class MoviesTab extends ConsumerStatefulWidget {
|
||||||
@@ -23,7 +27,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
|||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final List<Movie> _movies = [];
|
final List<Movie> _movies = [];
|
||||||
List<Movie>? _searchResults; // Results from full catalog search
|
List<Movie>? _searchResults;
|
||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isSearching = false;
|
bool _isSearching = false;
|
||||||
@@ -103,7 +107,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final service = ref.read(xtreamServiceProvider(widget.playlist));
|
final service = ref.read(xtreamServiceProvider(widget.playlist));
|
||||||
final newMovies = await service.getMovies(
|
final newMovies = await service.getMoviesPaginated(
|
||||||
offset: _currentOffset,
|
offset: _currentOffset,
|
||||||
limit: _pageSize,
|
limit: _pageSize,
|
||||||
);
|
);
|
||||||
@@ -126,252 +130,177 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _playMovie(Movie movie) {
|
||||||
|
ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId);
|
||||||
|
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => PlayerScreen(
|
||||||
|
streamId: movie.streamId,
|
||||||
|
title: movie.name,
|
||||||
|
playlist: widget.playlist,
|
||||||
|
streamType: StreamType.vod,
|
||||||
|
containerExtension: movie.containerExtension ?? 'mp4',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final settings = ref.watch(iptvSettingsProvider);
|
final settings = ref.watch(iptvSettingsProvider);
|
||||||
|
final watchHistory = ref.watch(watchHistoryProvider);
|
||||||
|
|
||||||
// Use search results if searching, otherwise use loaded movies with category filter
|
|
||||||
List<Movie> displayMovies;
|
List<Movie> displayMovies;
|
||||||
if (_searchQuery.isNotEmpty && _searchResults != null) {
|
if (_searchQuery.isNotEmpty && _searchResults != null) {
|
||||||
displayMovies = _searchResults!;
|
displayMovies = _searchResults!;
|
||||||
} else {
|
} else {
|
||||||
displayMovies = settings.moviesKeywords.isEmpty
|
displayMovies = settings.moviesKeywords.isEmpty
|
||||||
? _movies
|
? _movies
|
||||||
: _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList();
|
: _movies.where((m) => settings.matchesMovieFilter(m.categoryName)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hero Items (Take 5 random or first 5)
|
||||||
|
final heroItems = _movies.take(5).map((m) => HeroItem(
|
||||||
|
id: m.streamId,
|
||||||
|
title: m.name,
|
||||||
|
imageUrl: m.streamIcon ?? '',
|
||||||
|
subtitle: m.rating != null ? '${m.rating} ★' : null,
|
||||||
|
onMoreInfo: () {
|
||||||
|
_playMovie(m);
|
||||||
|
},
|
||||||
|
)).toList();
|
||||||
|
|
||||||
if (_movies.isEmpty && !_isLoading) {
|
if (_movies.isEmpty && !_isLoading) {
|
||||||
return const Center(child: Text('No movies available'));
|
return const Center(child: Text('No movies available'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
final double gridItemRatio = 0.65;
|
||||||
children: [
|
final int crossAxisCount = ResponsiveLayout.value(
|
||||||
// Search bar
|
context,
|
||||||
Padding(
|
mobile: 3,
|
||||||
padding: const EdgeInsets.all(8.0),
|
tablet: 5,
|
||||||
child: TextField(
|
desktop: 7,
|
||||||
controller: _searchController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Search all movies...',
|
|
||||||
hintStyle: GoogleFonts.roboto(color: Colors.grey.shade500),
|
|
||||||
prefixIcon: _isSearching
|
|
||||||
? const Padding(
|
|
||||||
padding: EdgeInsets.all(12),
|
|
||||||
child: SizedBox(
|
|
||||||
width: 20, height: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.search, color: Colors.grey),
|
|
||||||
suffixIcon: _searchQuery.isNotEmpty
|
|
||||||
? IconButton(
|
|
||||||
icon: const Icon(Icons.clear, color: Colors.grey),
|
|
||||||
onPressed: () {
|
|
||||||
_searchController.clear();
|
|
||||||
_onSearchChanged('');
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
filled: true,
|
|
||||||
fillColor: Colors.grey.shade800,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
borderSide: BorderSide.none,
|
|
||||||
),
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
),
|
|
||||||
style: GoogleFonts.roboto(color: Colors.white),
|
|
||||||
onChanged: _onSearchChanged,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Results count
|
|
||||||
if (_searchQuery.isNotEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Text(
|
|
||||||
'${displayMovies.length} result${displayMovies.length != 1 ? 's' : ''} found',
|
|
||||||
style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Movie grid
|
|
||||||
Expanded(
|
|
||||||
child: displayMovies.isEmpty && _movies.isNotEmpty
|
|
||||||
? Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
_searchQuery.isNotEmpty ? Icons.search_off : Icons.filter_list_off,
|
|
||||||
size: 48,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
_searchQuery.isNotEmpty
|
|
||||||
? 'No movies match "$_searchQuery"'
|
|
||||||
: 'No movies match the filter',
|
|
||||||
style: GoogleFonts.roboto(color: Colors.grey),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: GridView.builder(
|
|
||||||
controller: _scrollController,
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: 7,
|
|
||||||
crossAxisSpacing: 6,
|
|
||||||
mainAxisSpacing: 6,
|
|
||||||
childAspectRatio: 0.65,
|
|
||||||
),
|
|
||||||
itemCount: displayMovies.length + (_hasMore && _searchQuery.isEmpty ? 1 : 0),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
if (index >= displayMovies.length) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
final movie = displayMovies[index];
|
|
||||||
return _MovieCard(
|
|
||||||
movie: movie,
|
|
||||||
playlist: widget.playlist,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MovieCard extends ConsumerWidget {
|
return CustomScrollView(
|
||||||
final Movie movie;
|
controller: _scrollController,
|
||||||
final PlaylistConfig playlist;
|
slivers: [
|
||||||
|
// Header
|
||||||
const _MovieCard({
|
SliverToBoxAdapter(
|
||||||
required this.movie,
|
child: Container(
|
||||||
required this.playlist,
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
});
|
child: Row(
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final watchHistory = ref.watch(watchHistoryProvider);
|
|
||||||
final isWatched = watchHistory.isMovieWatched(movie.streamId);
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () {
|
|
||||||
// Mark as watched when opening
|
|
||||||
ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId);
|
|
||||||
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => PlayerScreen(
|
|
||||||
streamId: movie.streamId,
|
|
||||||
title: movie.name,
|
|
||||||
playlist: playlist,
|
|
||||||
streamType: StreamType.vod,
|
|
||||||
containerExtension: movie.containerExtension ?? 'mp4',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onLongPress: () {
|
|
||||||
// Toggle watched status on long press
|
|
||||||
ref.read(watchHistoryProvider.notifier).toggleMovieWatched(movie.streamId);
|
|
||||||
},
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Text(
|
||||||
child: movie.streamIcon != null
|
'Movies',
|
||||||
? CachedNetworkImage(
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
imageUrl: movie.streamIcon!,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
placeholder: (context, url) => Container(
|
|
||||||
color: Colors.grey.shade800,
|
|
||||||
child: const Center(
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
errorWidget: (context, url, error) => Container(
|
|
||||||
color: Colors.grey.shade800,
|
|
||||||
child: const Icon(Icons.movie, size: 48),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
color: Colors.grey.shade800,
|
|
||||||
child: const Icon(Icons.movie, size: 48),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
const Spacer(),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(8),
|
width: 300,
|
||||||
child: Column(
|
height: 40,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
const Icon(Icons.search, size: 20, color: AppColors.textSecondary),
|
||||||
movie.name,
|
const SizedBox(width: 8),
|
||||||
style: GoogleFonts.roboto(
|
Expanded(
|
||||||
fontSize: 11,
|
child: TextField(
|
||||||
fontWeight: FontWeight.w500,
|
controller: _searchController,
|
||||||
|
style: const TextStyle(fontSize: 14, color: AppColors.textPrimary),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Search',
|
||||||
|
border: InputBorder.none,
|
||||||
|
enabledBorder: InputBorder.none,
|
||||||
|
focusedBorder: InputBorder.none,
|
||||||
|
contentPadding: EdgeInsets.only(bottom: 12),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onChanged: _onSearchChanged,
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
if (movie.rating != null) ...[
|
if (_isSearching)
|
||||||
const SizedBox(height: 2),
|
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
Row(
|
if (_searchQuery.isNotEmpty)
|
||||||
children: [
|
GestureDetector(
|
||||||
Icon(
|
onTap: () {
|
||||||
Icons.star,
|
_searchController.clear();
|
||||||
size: 12,
|
_onSearchChanged('');
|
||||||
color: Colors.amber.shade700,
|
},
|
||||||
),
|
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary),
|
||||||
const SizedBox(width: 2),
|
|
||||||
Text(
|
|
||||||
movie.rating!,
|
|
||||||
style: GoogleFonts.roboto(
|
|
||||||
fontSize: 10,
|
|
||||||
color: Colors.grey.shade600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Watched indicator
|
),
|
||||||
if (isWatched)
|
|
||||||
Positioned(
|
|
||||||
top: 4,
|
|
||||||
right: 4,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.green,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.check,
|
|
||||||
size: 14,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
// Hero Carousel
|
||||||
|
if (_searchQuery.isEmpty && heroItems.isNotEmpty)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
|
child: HeroCarousel(
|
||||||
|
items: heroItems,
|
||||||
|
onTap: (item) {
|
||||||
|
final movie = _movies.firstWhere((element) => element.streamId == item.id);
|
||||||
|
_playMovie(movie);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Grid
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: crossAxisCount,
|
||||||
|
crossAxisSpacing: 24,
|
||||||
|
mainAxisSpacing: 32,
|
||||||
|
childAspectRatio: gridItemRatio,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
if (index >= displayMovies.length) return null;
|
||||||
|
final movie = displayMovies[index];
|
||||||
|
final isWatched = watchHistory.isMovieWatched(movie.streamId);
|
||||||
|
|
||||||
|
return MediaCard(
|
||||||
|
title: movie.name,
|
||||||
|
imageUrl: movie.streamIcon,
|
||||||
|
subtitle: movie.rating != null ? '${movie.rating} ★' : null,
|
||||||
|
rating: movie.rating,
|
||||||
|
isWatched: isWatched,
|
||||||
|
onTap: () => _playMovie(movie),
|
||||||
|
onLongPress: () {
|
||||||
|
ref.read(watchHistoryProvider.notifier).toggleMovieWatched(movie.streamId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: displayMovies.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Loader
|
||||||
|
if (_isLoading)
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
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 '../../../core/models/playlist_config.dart';
|
import '../../../core/models/playlist_config.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../core/utils/responsive_layout.dart';
|
||||||
|
import '../../../core/widgets/components/hero_carousel.dart';
|
||||||
|
import '../../../core/widgets/components/ui_components.dart';
|
||||||
import '../models/xtream_models.dart';
|
import '../models/xtream_models.dart';
|
||||||
import '../providers/xtream_provider.dart';
|
import '../providers/xtream_provider.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
@@ -125,11 +129,22 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openSeries(Series series) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => SeriesDetailScreen(
|
||||||
|
series: series,
|
||||||
|
playlist: widget.playlist,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final settings = ref.watch(iptvSettingsProvider);
|
final settings = ref.watch(iptvSettingsProvider);
|
||||||
|
|
||||||
// Use search results if searching, otherwise use loaded series with category filter
|
|
||||||
List<Series> displaySeries;
|
List<Series> displaySeries;
|
||||||
if (_searchQuery.isNotEmpty && _searchResults != null) {
|
if (_searchQuery.isNotEmpty && _searchResults != null) {
|
||||||
displaySeries = _searchResults!;
|
displaySeries = _searchResults!;
|
||||||
@@ -143,194 +158,135 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
|
|||||||
return const Center(child: Text('No series available'));
|
return const Center(child: Text('No series available'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
// Hero Items
|
||||||
children: [
|
final heroItems = _series.take(5).map((s) => HeroItem(
|
||||||
// Search bar
|
id: s.seriesId.toString(), // ID logic might differ for Series
|
||||||
Padding(
|
title: s.name,
|
||||||
padding: const EdgeInsets.all(8.0),
|
imageUrl: s.cover ?? '',
|
||||||
child: TextField(
|
subtitle: s.rating != null ? '${s.rating} ★' : null,
|
||||||
controller: _searchController,
|
onMoreInfo: () => _openSeries(s),
|
||||||
decoration: InputDecoration(
|
)).toList();
|
||||||
hintText: 'Search all series...',
|
|
||||||
hintStyle: GoogleFonts.roboto(color: Colors.grey.shade500),
|
final double gridItemRatio = 0.65;
|
||||||
prefixIcon: _isSearching
|
final int crossAxisCount = ResponsiveLayout.value(
|
||||||
? const Padding(
|
context,
|
||||||
padding: EdgeInsets.all(12),
|
mobile: 3,
|
||||||
child: SizedBox(
|
tablet: 5,
|
||||||
width: 20, height: 20,
|
desktop: 7,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
);
|
||||||
),
|
|
||||||
)
|
return CustomScrollView(
|
||||||
: const Icon(Icons.search, color: Colors.grey),
|
controller: _scrollController,
|
||||||
suffixIcon: _searchQuery.isNotEmpty
|
slivers: [
|
||||||
? IconButton(
|
// Header
|
||||||
icon: const Icon(Icons.clear, color: Colors.grey),
|
SliverToBoxAdapter(
|
||||||
onPressed: () {
|
child: Container(
|
||||||
_searchController.clear();
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
_onSearchChanged('');
|
child: Row(
|
||||||
},
|
children: [
|
||||||
)
|
Text(
|
||||||
: null,
|
'TV Series',
|
||||||
filled: true,
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
fillColor: Colors.grey.shade800,
|
),
|
||||||
border: OutlineInputBorder(
|
const Spacer(),
|
||||||
borderRadius: BorderRadius.circular(8),
|
Container(
|
||||||
borderSide: BorderSide.none,
|
width: 300,
|
||||||
),
|
height: 40,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
decoration: BoxDecoration(
|
||||||
),
|
color: AppColors.surface,
|
||||||
style: GoogleFonts.roboto(color: Colors.white),
|
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
|
||||||
onChanged: _onSearchChanged,
|
border: Border.all(color: AppColors.border),
|
||||||
),
|
),
|
||||||
),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
// Results count
|
|
||||||
if (_searchQuery.isNotEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Text(
|
|
||||||
'${displaySeries.length} result${displaySeries.length != 1 ? 's' : ''} found',
|
|
||||||
style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Series grid
|
|
||||||
Expanded(
|
|
||||||
child: displaySeries.isEmpty && _series.isNotEmpty
|
|
||||||
? Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
const Icon(Icons.search, size: 20, color: AppColors.textSecondary),
|
||||||
_searchQuery.isNotEmpty ? Icons.search_off : Icons.filter_list_off,
|
const SizedBox(width: 8),
|
||||||
size: 48,
|
Expanded(
|
||||||
color: Colors.grey,
|
child: TextField(
|
||||||
),
|
controller: _searchController,
|
||||||
const SizedBox(height: 16),
|
style: const TextStyle(fontSize: 14, color: AppColors.textPrimary),
|
||||||
Text(
|
decoration: const InputDecoration(
|
||||||
_searchQuery.isNotEmpty
|
hintText: 'Search',
|
||||||
? 'No series match "$_searchQuery"'
|
border: InputBorder.none,
|
||||||
: 'No series match the filter',
|
enabledBorder: InputBorder.none,
|
||||||
style: GoogleFonts.roboto(color: Colors.grey),
|
focusedBorder: InputBorder.none,
|
||||||
|
contentPadding: EdgeInsets.only(bottom: 12),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onChanged: _onSearchChanged,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
if (_isSearching)
|
||||||
|
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
if (_searchQuery.isNotEmpty)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
_searchController.clear();
|
||||||
|
_onSearchChanged('');
|
||||||
|
},
|
||||||
|
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: GridView.builder(
|
|
||||||
controller: _scrollController,
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: 7,
|
|
||||||
crossAxisSpacing: 6,
|
|
||||||
mainAxisSpacing: 6,
|
|
||||||
childAspectRatio: 0.65,
|
|
||||||
),
|
|
||||||
itemCount: displaySeries.length + (_hasMore && _searchQuery.isEmpty ? 1 : 0),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
if (index >= displaySeries.length) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
final serie = displaySeries[index];
|
|
||||||
return _SeriesCard(series: serie, playlist: widget.playlist);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Hero Carousel
|
||||||
|
if (_searchQuery.isEmpty && heroItems.isNotEmpty)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
|
child: HeroCarousel(
|
||||||
|
items: heroItems,
|
||||||
|
onTap: (item) {
|
||||||
|
final series = _series.firstWhere((s) => s.name == item.title); // Fallback by title if ID mismatch
|
||||||
|
_openSeries(series);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Grid
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: crossAxisCount,
|
||||||
|
crossAxisSpacing: 24,
|
||||||
|
mainAxisSpacing: 32,
|
||||||
|
childAspectRatio: gridItemRatio,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
if (index >= displaySeries.length) return null;
|
||||||
|
final serie = displaySeries[index];
|
||||||
|
return MediaCard(
|
||||||
|
title: serie.name,
|
||||||
|
imageUrl: serie.cover,
|
||||||
|
subtitle: serie.rating != null ? '${serie.rating} ★' : null,
|
||||||
|
rating: serie.rating,
|
||||||
|
placeholderIcon: Icons.tv,
|
||||||
|
onTap: () => _openSeries(serie),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: displaySeries.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Loader
|
||||||
|
if (_isLoading)
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SeriesCard extends StatelessWidget {
|
|
||||||
final Series series;
|
|
||||||
final PlaylistConfig playlist;
|
|
||||||
|
|
||||||
const _SeriesCard({required this.series, required this.playlist});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Card(
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () {
|
|
||||||
// Navigate to series detail screen for episode selection
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => SeriesDetailScreen(
|
|
||||||
series: series,
|
|
||||||
playlist: playlist,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: series.cover != null
|
|
||||||
? CachedNetworkImage(
|
|
||||||
imageUrl: series.cover!,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
placeholder: (context, url) => Container(
|
|
||||||
color: Colors.grey.shade800,
|
|
||||||
child: const Center(
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
errorWidget: (context, url, error) => Container(
|
|
||||||
color: Colors.grey.shade800,
|
|
||||||
child: const Icon(Icons.tv, size: 48),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
color: Colors.grey.shade800,
|
|
||||||
child: const Icon(Icons.tv, size: 48),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
series.name,
|
|
||||||
style: GoogleFonts.roboto(
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
if (series.rating != null) ...[
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.star,
|
|
||||||
size: 12,
|
|
||||||
color: Colors.amber.shade700,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
Text(
|
|
||||||
series.rating!,
|
|
||||||
style: GoogleFonts.roboto(
|
|
||||||
fontSize: 10,
|
|
||||||
color: Colors.grey.shade600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user