feat: introduce core mobile UI components including login screen, IPTV tabs, theme, and scaffold.

This commit is contained in:
Michael committed 2025-12-13 09:04:09 +01:00
1 parent 985a46f777
commit 33fcfe12db
7 files changed
+708 -433

No files matched your search

@@ -1,10 +1,11 @@
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:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../../features/auth/providers/auth_provider.dart'; import '../../../../features/auth/providers/auth_provider.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../theme/mobile_theme.dart'; import '../../../theme/mobile_theme.dart';
import '../../../../core/widgets/components/ui_components.dart';
class MobileLoginScreen extends ConsumerStatefulWidget { class MobileLoginScreen extends ConsumerStatefulWidget {
const MobileLoginScreen({super.key}); const MobileLoginScreen({super.key});
@@ -19,6 +20,14 @@ class _MobileLoginScreenState extends ConsumerState<MobileLoginScreen> {
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
bool _obscurePassword = true; bool _obscurePassword = true;
@override
void initState() {
super.initState();
// Pre-fill for convenience (as requested in original file)
_usernameController.text = 'admin';
_passwordController.text = 'admin';
}
@override @override
void dispose() { void dispose() {
_usernameController.dispose(); _usernameController.dispose();
@@ -47,126 +56,212 @@ class _MobileLoginScreenState extends ConsumerState<MobileLoginScreen> {
return Theme( return Theme(
data: MobileTheme.darkTheme, data: MobileTheme.darkTheme,
child: Scaffold( child: Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
body: Center( body: Stack(
child: SingleChildScrollView( fit: StackFit.expand,
padding: const EdgeInsets.all(24.0), children: [
child: Column( // Cinematic Background Gradient
mainAxisSize: MainAxisSize.min, Container(
children: [ decoration: const BoxDecoration(
// Logo gradient: LinearGradient(
Container( begin: Alignment.topLeft,
width: 72, end: Alignment.bottomRight,
height: 72, colors: [
decoration: BoxDecoration( Color(0xFF000000), // Deep Black
color: AppColors.surface, Color(0xFF0F0F12), // Subtle Gray
borderRadius: BorderRadius.circular(16), Color(0xFF000000), // Back to Black
border: Border.all(color: AppColors.border), ],
),
child: const Icon(
Icons.live_tv,
color: AppColors.primary,
size: 36,
),
), ),
const SizedBox(height: 16), ),
const Text( ),
'XtremFlow', // Ambient Glow Top Left
style: TextStyle( Positioned(
fontSize: 28, top: -100,
fontWeight: FontWeight.w800, left: -100,
color: AppColors.textPrimary, child: Container(
letterSpacing: -0.5, width: 300,
), height: 300,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.primary.withOpacity(0.15),
boxShadow: [
BoxShadow(
color: AppColors.primary.withOpacity(0.15),
blurRadius: 150,
spreadRadius: 50,
),
],
), ),
const SizedBox(height: 4), ),
const Text( ),
'Mobile Edition',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
letterSpacing: 1.5,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 48),
// Form // Content
Form( Center(
key: _formKey, child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: GlassContainer(
borderRadius: 24,
opacity: 0.7,
padding: const EdgeInsets.all(32),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
TextFormField( // Logo / Icon
controller: _usernameController, Container(
style: const TextStyle(color: AppColors.textPrimary), width: 80,
decoration: const InputDecoration( height: 80,
labelText: 'Username', decoration: BoxDecoration(
prefixIcon: Icon(Icons.person_outline), color: Colors.white.withOpacity(0.1),
shape: BoxShape.circle,
border: Border.all(color: Colors.white24),
),
child: const Icon(
Icons.live_tv_rounded,
color: Colors.white,
size: 40,
), ),
validator: (value) =>
(value == null || value.trim().isEmpty) ? 'Required' : null,
textInputAction: TextInputAction.next,
), ),
const SizedBox(height: 16), const SizedBox(height: 24),
TextFormField( Text(
controller: _passwordController, 'XtremFlow',
obscureText: _obscurePassword, style: GoogleFonts.inter(
style: const TextStyle(color: AppColors.textPrimary), fontSize: 32,
decoration: InputDecoration( fontWeight: FontWeight.w700,
labelText: 'Password', color: Colors.white,
prefixIcon: const Icon(Icons.lock_outline), letterSpacing: -1.0,
suffixIcon: IconButton( ),
icon: Icon( ),
_obscurePassword Text(
? Icons.visibility_outlined 'Mobile Edition',
: Icons.visibility_off_outlined, style: GoogleFonts.inter(
fontSize: 14,
color: Colors.white54,
letterSpacing: 0.5,
),
),
const SizedBox(height: 48),
// Form
Form(
key: _formKey,
child: Column(
children: [
_buildInput(
controller: _usernameController,
label: 'Username',
icon: Icons.person_outline_rounded,
), ),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword), const SizedBox(height: 16),
), _buildInput(
), controller: _passwordController,
validator: (value) => label: 'Password',
(value == null || value.isEmpty) ? 'Required' : null, icon: Icons.lock_outline_rounded,
onFieldSubmitted: (_) => _handleLogin(), isPassword: true,
), obscureText: _obscurePassword,
if (authState.errorMessage != null) ...[ onTogglePassword: () => setState(() => _obscurePassword = !_obscurePassword),
const SizedBox(height: 16), onSubmitted: (_) => _handleLogin(),
Text( ),
authState.errorMessage!,
style: const TextStyle(color: AppColors.error, fontSize: 12), if (authState.errorMessage != null) ...[
textAlign: TextAlign.center, const SizedBox(height: 24),
), Container(
], padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
const SizedBox(height: 32), decoration: BoxDecoration(
SizedBox( color: AppColors.error.withOpacity(0.1),
width: double.infinity, borderRadius: BorderRadius.circular(8),
child: FilledButton( border: Border.all(color: AppColors.error.withOpacity(0.3)),
onPressed: authState.isLoading ? null : _handleLogin, ),
child: authState.isLoading child: Text(
? const SizedBox( authState.errorMessage!,
width: 20, style: GoogleFonts.inter(color: AppColors.error, fontSize: 13),
height: 20, textAlign: TextAlign.center,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black), ),
) ),
: const Text('Sign In'), ],
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
onPressed: authState.isLoading ? null : _handleLogin,
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 0,
).copyWith(
overlayColor: MaterialStateProperty.all(Colors.black12),
),
child: authState.isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black),
)
: Text(
'Sign In',
style: GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16),
),
),
),
],
), ),
), ),
], ],
), ),
), ),
const SizedBox(height: 24), ),
const Text(
'Default: admin / admin',
style: TextStyle(
fontSize: 12,
color: AppColors.textTertiary,
fontStyle: FontStyle.italic,
),
),
],
), ),
), ],
), ),
), ),
); );
} }
Widget _buildInput({
required TextEditingController controller,
required String label,
required IconData icon,
bool isPassword = false,
bool obscureText = false,
VoidCallback? onTogglePassword,
Function(String)? onSubmitted,
}) {
return TextFormField(
controller: controller,
obscureText: obscureText,
style: GoogleFonts.inter(color: Colors.white),
decoration: InputDecoration(
labelText: label,
labelStyle: GoogleFonts.inter(color: Colors.white54),
prefixIcon: Icon(icon, color: Colors.white54),
suffixIcon: isPassword
? IconButton(
icon: Icon(obscureText ? Icons.visibility_outlined : Icons.visibility_off_outlined, color: Colors.white38),
onPressed: onTogglePassword,
)
: null,
filled: true,
fillColor: Colors.white.withOpacity(0.1),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.white, width: 2), // White Focus
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
validator: (value) => (value == null || value.trim().isEmpty) ? 'Required' : null,
onFieldSubmitted: onSubmitted,
);
}
} }
@@ -8,6 +8,7 @@ import '../../../../features/iptv/providers/xtream_provider.dart';
import '../../../../features/iptv/providers/watch_history_provider.dart'; import '../../../../features/iptv/providers/watch_history_provider.dart';
import 'mobile_player_screen.dart'; import 'mobile_player_screen.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/glass_container.dart';
class MobileSeriesDetailScreen extends ConsumerStatefulWidget { class MobileSeriesDetailScreen extends ConsumerStatefulWidget {
final Series series; final Series series;
@@ -76,22 +77,39 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: AppColors.background, backgroundColor: Colors.black,
body: _isLoading body: Stack(
? const Center(child: CircularProgressIndicator()) fit: StackFit.expand,
: _error != null children: [
? Center( // Global Background
child: Column( Container(
mainAxisAlignment: MainAxisAlignment.center, decoration: const BoxDecoration(
children: [ gradient: RadialGradient(
const Icon(Icons.error_outline, size: 48, color: AppColors.error), center: Alignment.center,
const SizedBox(height: 16), radius: 1.5,
Text('Error: $_error', style: const TextStyle(color: AppColors.textSecondary)), colors: [Color(0xFF1C1C1E), Color(0xFF000000)],
TextButton(onPressed: _loadSeriesInfo, child: const Text('Retry')), ),
], ),
), ),
)
: _buildMobileContent(), if (_isLoading)
const Center(child: CircularProgressIndicator(color: Colors.white))
else if (_error != null)
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: AppColors.error),
const SizedBox(height: 16),
Text('Error: $_error', style: GoogleFonts.inter(color: Colors.white70)),
TextButton(onPressed: _loadSeriesInfo, child: const Text('Retry')),
],
),
)
else
_buildMobileContent(),
],
),
); );
} }
@@ -106,17 +124,16 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
SliverAppBar( SliverAppBar(
expandedHeight: 400, expandedHeight: 400,
pinned: true, pinned: true,
backgroundColor: AppColors.background, backgroundColor: Colors.transparent, // Let background show through
leading: Container( leading: Container(
margin: const EdgeInsets.all(8), margin: const EdgeInsets.all(8),
decoration: const BoxDecoration( decoration: BoxDecoration(
color: Colors.black45, color: Colors.black.withOpacity(0.5),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: IconButton( child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white), icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
tooltip: 'Back',
), ),
), ),
flexibleSpace: FlexibleSpaceBar( flexibleSpace: FlexibleSpaceBar(
@@ -137,8 +154,8 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
Colors.transparent, Colors.transparent,
AppColors.background.withOpacity(0.8), Colors.black.withOpacity(0.8),
AppColors.background, Colors.black, // Merge into body background
], ],
stops: const [0.5, 0.8, 1.0], stops: const [0.5, 0.8, 1.0],
), ),
@@ -146,9 +163,7 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
), ),
// Info Overlay // Info Overlay
Positioned( Positioned(
bottom: 16, bottom: 16, left: 16, right: 16,
left: 16,
right: 16,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -158,7 +173,7 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
style: GoogleFonts.inter( style: GoogleFonts.inter(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.textPrimary, color: Colors.white,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -169,13 +184,13 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
_formatRating(_seriesInfo!.rating!)!, _formatRating(_seriesInfo!.rating!)!,
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13), style: GoogleFonts.inter(color: Colors.white70, fontSize: 13),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
], ],
Text( Text(
'${_seriesInfo!.episodes.keys.length} Seasons', '${_seriesInfo!.episodes.keys.length} Seasons',
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13), style: GoogleFonts.inter(color: Colors.white70, fontSize: 13),
), ),
], ],
), ),
@@ -194,7 +209,7 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Text( child: Text(
_seriesInfo!.plot!, _seriesInfo!.plot!,
style: const TextStyle(color: AppColors.textSecondary, height: 1.4), style: GoogleFonts.inter(color: Colors.white70, height: 1.4),
maxLines: 4, maxLines: 4,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -219,10 +234,10 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
onSelected: (selected) { onSelected: (selected) {
if (selected) setState(() => _selectedSeason = seasonNum); if (selected) setState(() => _selectedSeason = seasonNum);
}, },
backgroundColor: AppColors.surface, backgroundColor: Colors.white.withOpacity(0.1),
selectedColor: AppColors.primary, selectedColor: AppColors.primary,
labelStyle: TextStyle( labelStyle: GoogleFonts.inter(
color: isSelected ? Colors.white : AppColors.textSecondary, color: isSelected ? Colors.white : Colors.white70,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
), ),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
@@ -238,12 +253,15 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
// Episodes List // Episodes List
SliverPadding( SliverPadding(
padding: const EdgeInsets.only(bottom: 32), padding: const EdgeInsets.fromLTRB(16, 0, 16, 32),
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
final episode = currentEpisodes[index]; final episode = currentEpisodes[index];
return _buildEpisodeTile(episode); return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildEpisodeTile(episode),
);
}, },
childCount: currentEpisodes.length, childCount: currentEpisodes.length,
), ),
@@ -262,67 +280,70 @@ class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScr
); );
final isWatched = watchHistory.isEpisodeWatched(episodeKey); final isWatched = watchHistory.isEpisodeWatched(episodeKey);
return InkWell( return GlassContainer(
onTap: () { borderRadius: 12,
ref.read(watchHistoryProvider.notifier).markEpisodeWatched(episodeKey); opacity: 0.1,
Navigator.push( padding: EdgeInsets.zero,
context, child: InkWell(
MaterialPageRoute( onTap: () {
builder: (context) => MobilePlayerScreen( ref.read(watchHistoryProvider.notifier).markEpisodeWatched(episodeKey);
streamId: episode.id, Navigator.push(
title: '${widget.series.name} - ${episode.title}', context,
playlist: widget.playlist, MaterialPageRoute(
streamType: StreamType.series, builder: (context) => MobilePlayerScreen(
containerExtension: episode.containerExtension ?? 'mkv', streamId: episode.id,
), title: '${widget.series.name} - ${episode.title}',
), playlist: widget.playlist,
); streamType: StreamType.series,
}, containerExtension: episode.containerExtension ?? 'mkv',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: Colors.white10)),
),
child: Row(
children: [
// Play Button / Indicator
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isWatched ? AppColors.primary : Colors.white10,
shape: BoxShape.circle,
),
child: Icon(
isWatched ? Icons.check : Icons.play_arrow,
color: isWatched ? Colors.white : AppColors.primary,
size: 20,
), ),
), ),
const SizedBox(width: 16), );
Expanded( },
child: Column( borderRadius: BorderRadius.circular(12),
crossAxisAlignment: CrossAxisAlignment.start, child: Padding(
children: [ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
Text( child: Row(
'E${episode.episodeNum} - ${episode.title}', children: [
style: const TextStyle( // Play Button / Indicator
color: AppColors.textPrimary, Container(
fontWeight: FontWeight.w600, width: 40,
fontSize: 14, height: 40,
), decoration: BoxDecoration(
maxLines: 1, color: isWatched ? AppColors.primary : Colors.white.withOpacity(0.1),
overflow: TextOverflow.ellipsis, shape: BoxShape.circle,
), ),
if (episode.durationSecs != null && episode.durationSecs! > 0) child: Icon(
isWatched ? Icons.check : Icons.play_arrow_rounded,
color: isWatched ? Colors.white : AppColors.primary,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text( Text(
_formatDuration(episode.durationSecs!), 'E${episode.episodeNum} - ${episode.title}',
style: const TextStyle(color: AppColors.textSecondary, fontSize: 12), style: GoogleFonts.inter(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
], if (episode.durationSecs != null && episode.durationSecs! > 0)
Text(
_formatDuration(episode.durationSecs!),
style: GoogleFonts.inter(color: Colors.white54, fontSize: 12),
),
],
),
), ),
), ],
], ),
), ),
), ),
); );
@@ -8,8 +8,7 @@ import '../screens/mobile_player_screen.dart';
import '../../../../core/models/iptv_models.dart'; import '../../../../core/models/iptv_models.dart';
import '../../../../core/models/playlist_config.dart'; import '../../../../core/models/playlist_config.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
import '../../../theme/mobile_theme.dart'; import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/components/ui_components.dart'; // For images/CachedNetworkImage if needed
class MobileLiveTVTab extends ConsumerStatefulWidget { class MobileLiveTVTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist; final PlaylistConfig playlist;
@@ -29,18 +28,10 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
void initState() { void initState() {
super.initState(); super.initState();
_searchController.addListener(() { _searchController.addListener(() {
setState(() { setState(() => _searchQuery = _searchController.text.toLowerCase());
_searchQuery = _searchController.text.toLowerCase();
});
}); });
} }
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist)); final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
@@ -48,19 +39,17 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
final settings = ref.watch(iptvSettingsProvider); final settings = ref.watch(iptvSettingsProvider);
return Scaffold( return Scaffold(
backgroundColor: AppColors.background, backgroundColor: Colors.transparent, // Handled by MobileScaffold
body: channelsAsync.when( body: channelsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (e, s) => Center(child: Text('Error: $e')), error: (e, s) => Center(child: Text('Error: $e', style: const TextStyle(color: Colors.white))),
data: (groupedChannels) { data: (groupedChannels) {
// Prepare categories
var categories = groupedChannels.keys.toList(); var categories = groupedChannels.keys.toList();
if (settings.liveTvKeywords.isNotEmpty) { if (settings.liveTvKeywords.isNotEmpty) {
categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList(); categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList();
} }
categories.sort(); categories.sort();
// Prepare channels
List<Channel> displayedChannels = []; List<Channel> displayedChannels = [];
if (_searchQuery.isNotEmpty) { if (_searchQuery.isNotEmpty) {
displayedChannels = groupedChannels.values.expand((l) => l) displayedChannels = groupedChannels.values.expand((l) => l)
@@ -71,7 +60,6 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
.where((c) => favorites.contains(c.streamId)) .where((c) => favorites.contains(c.streamId))
.toList(); .toList();
} else { } else {
// Default to first category if none selected
if (_selectedCategory == null && categories.isNotEmpty) { if (_selectedCategory == null && categories.isNotEmpty) {
_selectedCategory = categories.first; _selectedCategory = categories.first;
} }
@@ -81,45 +69,42 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
} }
return SafeArea( return SafeArea(
bottom: false, // For bottom nav bottom: false,
child: Column( child: Column(
children: [ children: [
// Search Bar // Header & Search
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.all(16),
child: Container( child: GlassContainer(
height: 48, borderRadius: 16,
decoration: BoxDecoration( opacity: 0.1,
color: AppColors.surface, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.search, color: AppColors.textSecondary), Expanded(
const SizedBox(width: 8), child: TextField(
Expanded( controller: _searchController,
child: TextField( style: GoogleFonts.inter(color: Colors.white),
controller: _searchController, decoration: InputDecoration(
style: const TextStyle(color: AppColors.textPrimary), hintText: 'Search channels...',
decoration: const InputDecoration( hintStyle: GoogleFonts.inter(color: Colors.white54),
hintText: 'Search channels', border: InputBorder.none,
border: InputBorder.none, prefixIcon: const Icon(Icons.search, color: Colors.white54),
isDense: true, isDense: true,
), ),
), ),
), ),
if (_searchQuery.isNotEmpty) if (_searchQuery.isNotEmpty)
GestureDetector( GestureDetector(
onTap: () => _searchController.clear(), onTap: () => _searchController.clear(),
child: const Icon(Icons.close, color: AppColors.textSecondary), child: const Icon(Icons.close, color: Colors.white54),
), ),
], ],
), ),
), ),
), ),
// Categories (Chips) // Categories
if (_searchQuery.isEmpty && !_showFavoritesOnly) if (_searchQuery.isEmpty && !_showFavoritesOnly)
SizedBox( SizedBox(
height: 40, height: 40,
@@ -131,71 +116,85 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final category = categories[index]; final category = categories[index];
final isSelected = category == _selectedCategory; final isSelected = category == _selectedCategory;
return ChoiceChip( return GestureDetector(
label: Text(category), onTap: () => setState(() => _selectedCategory = category),
selected: isSelected, child: AnimatedContainer(
onSelected: (bool selected) { duration: const Duration(milliseconds: 200),
if (selected) { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
setState(() { decoration: BoxDecoration(
_selectedCategory = category; color: isSelected ? Colors.white : Colors.white.withOpacity(0.1),
}); borderRadius: BorderRadius.circular(20),
} ),
}, child: Text(
backgroundColor: AppColors.surface, category,
selectedColor: AppColors.primary, style: GoogleFonts.inter(
labelStyle: TextStyle( color: isSelected ? Colors.black : Colors.white70,
color: isSelected ? Colors.white : AppColors.textSecondary, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, fontSize: 13,
fontSize: 12, ),
),
), ),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
side: BorderSide.none,
showCheckmark: false,
); );
}, },
), ),
), ),
// Content Switcher (Favorites button etc) // Sub-header
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row( child: Row(
children: [ children: [
Text( Text(
_searchQuery.isNotEmpty ? 'Search Results' : _searchQuery.isNotEmpty ? 'Results' :
_showFavoritesOnly ? 'Favorites' : _showFavoritesOnly ? 'Favorites' :
_selectedCategory ?? 'All Channels', _selectedCategory ?? 'Channels',
style: const TextStyle( style: GoogleFonts.inter(
color: AppColors.textPrimary, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16, fontSize: 18,
), ),
), ),
const Spacer(), const Spacer(),
IconButton( GestureDetector(
icon: Icon( onTap: () => setState(() => _showFavoritesOnly = !_showFavoritesOnly),
_showFavoritesOnly ? Icons.favorite : Icons.favorite_border, child: Container(
color: _showFavoritesOnly ? AppColors.error : AppColors.textSecondary, padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _showFavoritesOnly ? AppColors.live.withOpacity(0.2) : Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: _showFavoritesOnly ? Border.all(color: AppColors.live) : null,
),
child: Icon(
_showFavoritesOnly ? Icons.favorite : Icons.favorite_border,
color: _showFavoritesOnly ? AppColors.live : Colors.white,
size: 20,
),
), ),
onPressed: () => setState(() => _showFavoritesOnly = !_showFavoritesOnly),
), ),
], ],
), ),
), ),
// Channel List // List
Expanded( Expanded(
child: displayedChannels.isEmpty child: displayedChannels.isEmpty
? Center( ? Center(
child: Text( child: Column(
'No channels found', mainAxisAlignment: MainAxisAlignment.center,
style: GoogleFonts.inter(color: AppColors.textSecondary), children: [
const Icon(Icons.tv_off, color: Colors.white24, size: 48),
const SizedBox(height: 16),
Text(
'No channels found',
style: GoogleFonts.inter(color: Colors.white38),
),
],
), ),
) )
: ListView.separated( : ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), // Bottom padding for FAB/Nav padding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
itemCount: displayedChannels.length, itemCount: displayedChannels.length,
separatorBuilder: (_, __) => const SizedBox(height: 12), separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final channel = displayedChannels[index]; final channel = displayedChannels[index];
return _MobileChannelTile( return _MobileChannelTile(
@@ -239,50 +238,68 @@ class _MobileChannelTile extends StatelessWidget {
? '/api/xtream/${channel.streamIcon}' ? '/api/xtream/${channel.streamIcon}'
: null; : null;
return InkWell( return GlassContainer(
onTap: onTap, borderRadius: 12,
borderRadius: BorderRadius.circular(12), opacity: 0.15, // Slightly more visible
child: Container( padding: EdgeInsets.zero,
padding: const EdgeInsets.all(8), child: InkWell(
decoration: BoxDecoration( onTap: onTap,
color: AppColors.surface, borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(12), child: Padding(
), padding: const EdgeInsets.all(12),
child: Row( child: Row(
children: [ children: [
// Icon Container(
Container( width: 48,
width: 50, height: 48,
height: 50, decoration: BoxDecoration(
decoration: BoxDecoration( color: Colors.white.withOpacity(0.1),
color: Colors.black26, borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(8),
),
child: iconUrl != null
? Image.network(
iconUrl,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const Icon(Icons.tv, color: Colors.white24),
)
: const Icon(Icons.tv, color: Colors.white24),
),
const SizedBox(width: 12),
// Name
Expanded(
child: Text(
channel.name,
style: const TextStyle(
color: AppColors.textPrimary,
fontWeight: FontWeight.w500,
fontSize: 14,
), ),
maxLines: 2, padding: const EdgeInsets.all(4),
overflow: TextOverflow.ellipsis, child: iconUrl != null
? Image.network(
iconUrl,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const Icon(Icons.tv, color: Colors.white24),
)
: const Icon(Icons.tv, color: Colors.white24),
), ),
), const SizedBox(width: 16),
// Play Icon Expanded(
const Icon(Icons.play_circle_outline, color: AppColors.primary), child: Column(
], crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
channel.name,
style: GoogleFonts.inter(
color: Colors.white,
fontWeight: FontWeight.w500,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'#${channel.num}',
style: GoogleFonts.inter(
color: Colors.white38,
fontSize: 12,
),
),
],
),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 16),
),
],
),
), ),
), ),
); );
@@ -1,10 +1,11 @@
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: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_colors.dart';
import '../../../../core/widgets/components/hero_carousel.dart'; import '../../../../core/widgets/components/hero_carousel.dart';
import '../../../../core/widgets/components/ui_components.dart'; import '../../../../core/widgets/glass_container.dart';
import '../../../../features/iptv/providers/watch_history_provider.dart'; import '../../../../features/iptv/providers/watch_history_provider.dart';
import '../../../../features/iptv/models/xtream_models.dart'; import '../../../../features/iptv/models/xtream_models.dart';
import '../../../../features/iptv/providers/xtream_provider.dart'; import '../../../../features/iptv/providers/xtream_provider.dart';
@@ -29,7 +30,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
bool _isSearching = false; bool _isSearching = false;
bool _hasMore = true; bool _hasMore = true;
int _currentOffset = 0; int _currentOffset = 0;
static const int _pageSize = 50; // Smaller page size for mobile static const int _pageSize = 50;
Timer? _searchDebounce; Timer? _searchDebounce;
@override @override
@@ -48,7 +49,6 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
} }
void _onScroll() { void _onScroll() {
// Only trigger load when near bottom and not already loading
if (!_isLoading && _hasMore && if (!_isLoading && _hasMore &&
_scrollController.position.pixels >= _scrollController.position.pixels >=
_scrollController.position.maxScrollExtent * 0.8) { _scrollController.position.maxScrollExtent * 0.8) {
@@ -93,7 +93,6 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
} }
Future<void> _loadMoreMovies() async { Future<void> _loadMoreMovies() async {
// Prevent race conditions
if (_isLoading || !_hasMore || !mounted) return; if (_isLoading || !_hasMore || !mounted) return;
setState(() => _isLoading = true); setState(() => _isLoading = true);
@@ -129,7 +128,6 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
return rating; return rating;
} }
/// Proxy HTTP images through backend to avoid CORS/mixed-content issues
String _getProxiedImageUrl(String? originalUrl) { String _getProxiedImageUrl(String? originalUrl) {
if (originalUrl == null || originalUrl.isEmpty) return ''; if (originalUrl == null || originalUrl.isEmpty) return '';
if (originalUrl.startsWith('http://')) { if (originalUrl.startsWith('http://')) {
@@ -169,7 +167,6 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
: _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList(); : _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList();
} }
// Hero Items (use filtered movies)
final heroItems = displayMovies.take(3).map((m) => HeroItem( final heroItems = displayMovies.take(3).map((m) => HeroItem(
id: m.streamId, id: m.streamId,
title: m.name, title: m.name,
@@ -183,44 +180,41 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
child: CustomScrollView( child: CustomScrollView(
controller: _scrollController, controller: _scrollController,
slivers: [ slivers: [
// Header & Search // Glass Header & Search
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Container( child: GlassContainer(
height: 40, borderRadius: 100,
decoration: BoxDecoration( opacity: 0.1, // Glass
color: AppColors.surface, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.search, size: 20, color: AppColors.textSecondary), const Icon(Icons.search, size: 20, color: Colors.white54),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
style: const TextStyle(fontSize: 14, color: AppColors.textPrimary), style: GoogleFonts.inter(fontSize: 14, color: Colors.white),
decoration: const InputDecoration( decoration: InputDecoration(
hintText: 'Search Movies', hintText: 'Search Movies...',
hintStyle: GoogleFonts.inter(color: Colors.white54),
border: InputBorder.none, border: InputBorder.none,
isDense: true, isDense: true,
contentPadding: EdgeInsets.only(bottom: 11), contentPadding: const EdgeInsets.only(bottom: 11),
), ),
onChanged: _onSearchChanged, onChanged: _onSearchChanged,
), ),
), ),
if (_isSearching) if (_isSearching)
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)), const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)),
if (_searchQuery.isNotEmpty) if (_searchQuery.isNotEmpty)
GestureDetector( GestureDetector(
onTap: () { onTap: () {
_searchController.clear(); _searchController.clear();
_onSearchChanged(''); _onSearchChanged('');
}, },
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary), child: const Icon(Icons.close, size: 16, color: Colors.white),
), ),
], ],
), ),
@@ -228,13 +222,13 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
), ),
), ),
// Hero Section (Only if not searching) // Hero Section
if (_searchQuery.isEmpty && heroItems.isNotEmpty) if (_searchQuery.isEmpty && heroItems.isNotEmpty)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 24),
child: SizedBox( child: SizedBox(
height: 250, // Reduced height for mobile hero height: 250,
child: HeroCarousel( child: HeroCarousel(
items: heroItems, items: heroItems,
onTap: (item) { onTap: (item) {
@@ -248,13 +242,13 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
// Grid // Grid
SliverPadding( SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), // Bottom padding for nav padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
sliver: SliverGrid( sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // 2 columns for mobile crossAxisCount: 2,
crossAxisSpacing: 12, crossAxisSpacing: 16,
mainAxisSpacing: 16, mainAxisSpacing: 16,
childAspectRatio: 0.65, // Standard poster ratio childAspectRatio: 0.65,
), ),
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
@@ -262,17 +256,85 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
final movie = displayMovies[index]; final movie = displayMovies[index];
final isWatched = watchHistory.isMovieWatched(movie.streamId); final isWatched = watchHistory.isMovieWatched(movie.streamId);
return MediaCard( return GestureDetector(
title: movie.name,
imageUrl: _getProxiedImageUrl(movie.streamIcon),
subtitle: movie.rating != null ? '${_formatRating(movie.rating)} ★' : null,
rating: _formatRating(movie.rating),
isWatched: isWatched,
onTap: () => _playMovie(movie), onTap: () => _playMovie(movie),
onLongPress: () { onLongPress: () {
ref.read(watchHistoryProvider.notifier).toggleMovieWatched(movie.streamId); ref.read(watchHistoryProvider.notifier).toggleMovieWatched(movie.streamId);
}, },
child: Stack(
fit: StackFit.expand,
children: [
// Poster
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: movie.streamIcon != null && movie.streamIcon!.isNotEmpty
? Image.network(
_getProxiedImageUrl(movie.streamIcon),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(color: Colors.white10),
)
: Container(color: Colors.white10, child: const Icon(Icons.movie, color: Colors.white24)),
),
// Gradient Overlay
Positioned(
bottom: 0, left: 0, right: 0,
height: 80,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withOpacity(0.9)],
),
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
),
),
),
// Title
Positioned(
bottom: 12, left: 12, right: 12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
movie.name,
style: GoogleFonts.inter(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (isWatched)
Text('WATCHED', style: GoogleFonts.inter(color: AppColors.primary, fontSize: 10, fontWeight: FontWeight.bold)),
],
),
),
// Rating
if (movie.rating != null && movie.rating!.isNotEmpty)
Positioned(
top: 8, right: 8,
child: GlassContainer(
borderRadius: 4,
opacity: 0.6,
color: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.star, size: 10, color: Colors.amber),
const SizedBox(width: 4),
Text(
_formatRating(movie.rating)!,
style: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white),
),
],
),
),
),
],
),
); );
}, },
childCount: displayMovies.length, childCount: displayMovies.length,
@@ -284,7 +346,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
const SliverToBoxAdapter( const SliverToBoxAdapter(
child: Padding( child: Padding(
padding: EdgeInsets.all(24), padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator(color: Colors.white)),
), ),
), ),
], ],
@@ -1,11 +1,11 @@
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: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_colors.dart';
import '../../../../core/widgets/components/hero_carousel.dart'; import '../../../../core/widgets/components/hero_carousel.dart';
import '../../../../core/widgets/components/ui_components.dart'; import '../../../../core/widgets/glass_container.dart';
import '../../../../features/iptv/models/xtream_models.dart'; import '../../../../features/iptv/models/xtream_models.dart';
import '../../../../features/iptv/providers/xtream_provider.dart'; import '../../../../features/iptv/providers/xtream_provider.dart';
import '../../../../features/iptv/providers/settings_provider.dart'; import '../../../../features/iptv/providers/settings_provider.dart';
@@ -48,7 +48,6 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
} }
void _onScroll() { void _onScroll() {
// Only trigger load when near bottom and not already loading
if (!_isLoading && _hasMore && if (!_isLoading && _hasMore &&
_scrollController.position.pixels >= _scrollController.position.pixels >=
_scrollController.position.maxScrollExtent * 0.8) { _scrollController.position.maxScrollExtent * 0.8) {
@@ -93,7 +92,6 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
} }
Future<void> _loadMoreSeries() async { Future<void> _loadMoreSeries() async {
// Prevent race conditions
if (_isLoading || !_hasMore || !mounted) return; if (_isLoading || !_hasMore || !mounted) return;
setState(() => _isLoading = true); setState(() => _isLoading = true);
@@ -129,7 +127,6 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
return rating; return rating;
} }
/// Proxy HTTP images through backend to avoid CORS/mixed-content issues
String _getProxiedImageUrl(String? originalUrl) { String _getProxiedImageUrl(String? originalUrl) {
if (originalUrl == null || originalUrl.isEmpty) return ''; if (originalUrl == null || originalUrl.isEmpty) return '';
if (originalUrl.startsWith('http://')) { if (originalUrl.startsWith('http://')) {
@@ -163,7 +160,6 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
: _series.where((s) => settings.matchesSeriesFilter(s.categoryName)).toList(); : _series.where((s) => settings.matchesSeriesFilter(s.categoryName)).toList();
} }
// Hero Items (use filtered series)
final heroItems = displaySeries.take(3).map((s) => HeroItem( final heroItems = displaySeries.take(3).map((s) => HeroItem(
id: s.seriesId.toString(), id: s.seriesId.toString(),
title: s.name, title: s.name,
@@ -177,44 +173,41 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
child: CustomScrollView( child: CustomScrollView(
controller: _scrollController, controller: _scrollController,
slivers: [ slivers: [
// Header & Search // Glass Header & Search
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Container( child: GlassContainer(
height: 40, borderRadius: 100,
decoration: BoxDecoration( opacity: 0.1,
color: AppColors.surface, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.search, size: 20, color: AppColors.textSecondary), const Icon(Icons.search, size: 20, color: Colors.white54),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
style: const TextStyle(fontSize: 14, color: AppColors.textPrimary), style: GoogleFonts.inter(fontSize: 14, color: Colors.white),
decoration: const InputDecoration( decoration: InputDecoration(
hintText: 'Search Series', hintText: 'Search Series...',
hintStyle: GoogleFonts.inter(color: Colors.white54),
border: InputBorder.none, border: InputBorder.none,
isDense: true, isDense: true,
contentPadding: EdgeInsets.only(bottom: 11), contentPadding: const EdgeInsets.only(bottom: 11),
), ),
onChanged: _onSearchChanged, onChanged: _onSearchChanged,
), ),
), ),
if (_isSearching) if (_isSearching)
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)), const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)),
if (_searchQuery.isNotEmpty) if (_searchQuery.isNotEmpty)
GestureDetector( GestureDetector(
onTap: () { onTap: () {
_searchController.clear(); _searchController.clear();
_onSearchChanged(''); _onSearchChanged('');
}, },
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary), child: const Icon(Icons.close, size: 16, color: Colors.white),
), ),
], ],
), ),
@@ -226,21 +219,17 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
if (_searchQuery.isEmpty && heroItems.isNotEmpty) if (_searchQuery.isEmpty && heroItems.isNotEmpty)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 24),
child: SizedBox( child: SizedBox(
height: 250, height: 250,
child: HeroCarousel( child: HeroCarousel(
items: heroItems, items: heroItems,
onTap: (item) { onTap: (item) {
// Find series by title fallback if ID logic differs (Series ID is int usually, Hero is Str)
// But here we rely on passing the correct string ID or using the closure directly if possible.
// HeroCarousel onTap returns HeroItem.
// We must map it back.
try { try {
final series = _series.firstWhere((s) => s.seriesId.toString() == item.id); final series = _series.firstWhere((s) => s.seriesId.toString() == item.id);
_openSeries(series); _openSeries(series);
} catch (e) { } catch (e) {
// Fallback logic not critical for now, assume ID matches // Fallback
} }
}, },
), ),
@@ -263,14 +252,74 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
if (index >= displaySeries.length) return null; if (index >= displaySeries.length) return null;
final series = displaySeries[index]; final series = displaySeries[index];
return MediaCard( return GestureDetector(
title: series.name,
imageUrl: _getProxiedImageUrl(series.cover),
subtitle: series.rating != null ? '${_formatRating(series.rating)} ★' : null,
rating: _formatRating(series.rating),
placeholderIcon: Icons.tv,
onTap: () => _openSeries(series), onTap: () => _openSeries(series),
child: Stack(
fit: StackFit.expand,
children: [
// Poster
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: series.cover != null && series.cover!.isNotEmpty
? Image.network(
_getProxiedImageUrl(series.cover),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(color: Colors.white10),
)
: Container(color: Colors.white10, child: const Icon(Icons.tv, color: Colors.white24)),
),
// Gradient Overlay
Positioned(
bottom: 0, left: 0, right: 0,
height: 80,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withOpacity(0.9)],
),
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
),
),
),
// Title
Positioned(
bottom: 12, left: 12, right: 12,
child: Text(
series.name,
style: GoogleFonts.inter(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
// Rating
if (series.rating != null && series.rating!.isNotEmpty)
Positioned(
top: 8, right: 8,
child: GlassContainer(
borderRadius: 4,
opacity: 0.6,
color: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.star, size: 10, color: Colors.amber),
const SizedBox(width: 4),
Text(
_formatRating(series.rating)!,
style: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white),
),
],
),
),
),
],
),
); );
}, },
childCount: displaySeries.length, childCount: displaySeries.length,
@@ -282,7 +331,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
const SliverToBoxAdapter( const SliverToBoxAdapter(
child: Padding( child: Padding(
padding: EdgeInsets.all(24), padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator(color: Colors.white)),
), ),
), ),
], ],
+9 -9
View File
@@ -43,25 +43,25 @@ class MobileTheme {
// AppBar: Transparent / Glass // AppBar: Transparent / Glass
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: AppColors.surface.withOpacity(0.8), // Slight background for mobile backgroundColor: Colors.transparent, // Let GlassContainer handle background
foregroundColor: AppColors.textPrimary, foregroundColor: Colors.white,
elevation: 0, elevation: 0,
centerTitle: true, centerTitle: true,
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
titleTextStyle: GoogleFonts.inter( titleTextStyle: GoogleFonts.inter(
fontSize: 16, fontSize: 18,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textPrimary, color: Colors.white,
), ),
), ),
// Bottom Navigation Bar // Bottom Navigation Bar
bottomNavigationBarTheme: BottomNavigationBarThemeData( bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: AppColors.surface.withOpacity(0.9), backgroundColor: const Color(0xFF1C1C1E).withOpacity(0.9), // Dark glass
selectedItemColor: AppColors.primary, selectedItemColor: Colors.white,
unselectedItemColor: AppColors.textSecondary, unselectedItemColor: Colors.white38,
selectedLabelStyle: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.w600), selectedLabelStyle: GoogleFonts.inter(fontSize: 11, fontWeight: FontWeight.w600),
unselectedLabelStyle: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.w500), unselectedLabelStyle: GoogleFonts.inter(fontSize: 11, fontWeight: FontWeight.w500),
type: BottomNavigationBarType.fixed, type: BottomNavigationBarType.fixed,
elevation: 0, elevation: 0,
), ),
+63 -32
View File
@@ -1,5 +1,7 @@
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 'dart:ui';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
class MobileScaffold extends ConsumerWidget { class MobileScaffold extends ConsumerWidget {
@@ -17,41 +19,70 @@ class MobileScaffold extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return Scaffold( return Scaffold(
extendBody: true, // Allow content behind nav bar if transparent extendBody: true,
backgroundColor: AppColors.background, backgroundColor: Colors.black, // Base color
body: child, body: Stack(
bottomNavigationBar: Container( fit: StackFit.expand,
decoration: BoxDecoration( children: [
border: Border(top: BorderSide(color: AppColors.border.withOpacity(0.5), width: 0.5)), // Global Background
), Container(
child: BottomNavigationBar( decoration: const BoxDecoration(
currentIndex: currentIndex, gradient: RadialGradient(
onTap: onIndexChanged, center: Alignment.center,
backgroundColor: AppColors.surface.withOpacity(0.95), // Highly opaque for readability radius: 1.5,
type: BottomNavigationBarType.fixed, colors: [
showSelectedLabels: true, Color(0xFF1C1C1E),
showUnselectedLabels: true, Color(0xFF000000),
items: const [ ],
BottomNavigationBarItem( ),
icon: Icon(Icons.tv),
label: 'Live',
), ),
BottomNavigationBarItem( ),
icon: Icon(Icons.movie_outlined),
activeIcon: Icon(Icons.movie), // Content
label: 'Movies', child,
],
),
bottomNavigationBar: ClipRRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF1C1C1E).withOpacity(0.85),
border: Border(top: BorderSide(color: Colors.white.withOpacity(0.1), width: 0.5)),
), ),
BottomNavigationBarItem( child: BottomNavigationBar(
icon: Icon(Icons.video_library_outlined), currentIndex: currentIndex,
activeIcon: Icon(Icons.video_library), onTap: onIndexChanged,
label: 'Series', backgroundColor: Colors.transparent,
type: BottomNavigationBarType.fixed,
elevation: 0,
selectedItemColor: Colors.white,
unselectedItemColor: Colors.white38,
selectedLabelStyle: GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 11),
unselectedLabelStyle: GoogleFonts.inter(fontWeight: FontWeight.w500, fontSize: 11),
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.live_tv_rounded),
label: 'Live',
),
BottomNavigationBarItem(
icon: Icon(Icons.movie_outlined),
activeIcon: Icon(Icons.movie_rounded),
label: 'Movies',
),
BottomNavigationBarItem(
icon: Icon(Icons.video_library_outlined),
activeIcon: Icon(Icons.video_library_rounded),
label: 'Series',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings_rounded),
label: 'Settings',
),
],
), ),
BottomNavigationBarItem( ),
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings),
label: 'Settings',
),
],
), ),
), ),
); );