mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-21 06:08:15 +02:00
feat: Implement MobileMoviesTab for displaying IPTV movies with category browsing and search functionality.
This commit is contained in:
1 parent
0010fe12ba
commit
f85f43e42f
17 files changed
+268
-136
No files matched your search
@@ -147,7 +147,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
}
|
||||
|
||||
Future<void> _updateEPG() async {
|
||||
if (widget.streamType != model.StreamType.live || _xtreamService == null) return;
|
||||
if (widget.streamType != model.StreamType.live || _xtreamService == null)
|
||||
return;
|
||||
try {
|
||||
final currentChannelId =
|
||||
widget.channels?[_currentIndex].streamId ?? widget.streamId;
|
||||
@@ -198,10 +199,12 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
);
|
||||
|
||||
_controller = controller;
|
||||
await controller.initialize().timeout(const Duration(seconds: 15),
|
||||
onTimeout: () {
|
||||
throw Exception('Connection timed out');
|
||||
});
|
||||
await controller.initialize().timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () {
|
||||
throw Exception('Connection timed out');
|
||||
},
|
||||
);
|
||||
|
||||
if (!mounted) {
|
||||
await controller.dispose();
|
||||
@@ -221,11 +224,12 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
_resetControlsTimer();
|
||||
controller.addListener(_videoListener);
|
||||
} catch (e) {
|
||||
if (mounted && currentLoadId == _loadId)
|
||||
if (mounted && currentLoadId == _loadId) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = "Playback Error: $e";
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,8 +237,9 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
if (_controller == null || !mounted) return;
|
||||
final value = _controller!.value;
|
||||
|
||||
if (value.isPlaying != _isPlaying)
|
||||
if (value.isPlaying != _isPlaying) {
|
||||
setState(() => _isPlaying = value.isPlaying);
|
||||
}
|
||||
|
||||
// Stabilization Masking: spinner stays until 1.5s of smooth play
|
||||
// Fix: If playing, ignore isBuffering to prevent infinite spinner on some TS streams
|
||||
@@ -302,16 +307,18 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
final key = event.logicalKey;
|
||||
|
||||
if (key == LogicalKeyboardKey.channelUp) {
|
||||
if (widget.channels != null)
|
||||
if (widget.channels != null) {
|
||||
_switchChannel((_currentIndex + 1) % widget.channels!.length);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.channelDown) {
|
||||
if (widget.channels != null)
|
||||
if (widget.channels != null) {
|
||||
_switchChannel(
|
||||
(_currentIndex - 1 + widget.channels!.length) %
|
||||
widget.channels!.length,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -393,7 +400,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black45,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
@@ -408,7 +415,9 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
if (settings.showClock)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 6),
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black45,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
@@ -460,9 +469,10 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
|
||||
Widget _buildAspectRatioWrapper() {
|
||||
final mode = ref.read(mobileSettingsProvider).aspectRatioMode;
|
||||
if (mode == 'fill')
|
||||
if (mode == 'fill') {
|
||||
return SizedBox.expand(child: VideoPlayer(_controller!));
|
||||
if (mode == 'cover')
|
||||
}
|
||||
if (mode == 'cover') {
|
||||
return SizedBox.expand(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.cover,
|
||||
@@ -473,6 +483,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return AspectRatio(
|
||||
aspectRatio: _controller!.value.aspectRatio,
|
||||
child: VideoPlayer(_controller!),
|
||||
|
||||
@@ -16,7 +16,7 @@ import 'package:xtremobile/features/iptv/screens/lite_player_screen.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:xtremobile/core/utils/device_info.dart';
|
||||
|
||||
/// Stream type enum for player
|
||||
/// Stream type enum for player
|
||||
// (Moved to global or imported from iptv_models if available)
|
||||
|
||||
/// Native video player screen for Android/iOS
|
||||
@@ -165,7 +165,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
'MediaKitPlayer: Applying LIVE optimization profile (STABILITY)',
|
||||
);
|
||||
(_player.platform as dynamic)?.setProperty(
|
||||
'cache-secs', '10'); // Reduced to 10s for faster startup
|
||||
'cache-secs',
|
||||
'10',
|
||||
); // Reduced to 10s for faster startup
|
||||
(_player.platform as dynamic)?.setProperty(
|
||||
'demuxer-max-bytes',
|
||||
'32000000',
|
||||
@@ -190,7 +192,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
final backBytes = (bufferBytes / 10).toInt(); // 10% for backward buffer
|
||||
|
||||
debugPrint(
|
||||
'MediaKitPlayer: VOD profile - ${deviceInfo.getDeviceProfile()}, buffer=${bufferBytes ~/ (1024 * 1024)}MB');
|
||||
'MediaKitPlayer: VOD profile - ${deviceInfo.getDeviceProfile()}, buffer=${bufferBytes ~/ (1024 * 1024)}MB',
|
||||
);
|
||||
|
||||
(_player.platform as dynamic)?.setProperty('cache-secs', '100');
|
||||
(_player.platform as dynamic)
|
||||
@@ -309,7 +312,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
_checkAndMarkWatched(position);
|
||||
|
||||
// Auto-save position every 10 seconds (resilience against app kill/crash)
|
||||
if (widget.streamType != model.StreamType.live && !_hasMarkedAsWatched) {
|
||||
if (widget.streamType != model.StreamType.live &&
|
||||
!_hasMarkedAsWatched) {
|
||||
final now = DateTime.now();
|
||||
if (_lastSaveTime == null ||
|
||||
now.difference(_lastSaveTime!) > const Duration(seconds: 10)) {
|
||||
@@ -594,7 +598,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
Future.delayed(Duration(seconds: 2 * _reconnectAttempts), () {
|
||||
if (mounted) {
|
||||
// If VOD/Series, try to resume from current position
|
||||
if (widget.streamType != model.StreamType.live && _position.inSeconds > 0) {
|
||||
if (widget.streamType != model.StreamType.live &&
|
||||
_position.inSeconds > 0) {
|
||||
_loadStream(startAt: _position);
|
||||
} else {
|
||||
_loadStream();
|
||||
@@ -949,7 +954,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
|
||||
// Channel Down = Previous Channel (inverted as requested)
|
||||
if (key == LogicalKeyboardKey.channelDown) {
|
||||
if (widget.streamType == model.StreamType.live && widget.channels != null) {
|
||||
if (widget.streamType == model.StreamType.live &&
|
||||
widget.channels != null) {
|
||||
_playPrevious();
|
||||
_onUserInteraction();
|
||||
return true;
|
||||
@@ -958,7 +964,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
|
||||
// Channel Up = Next Channel (inverted as requested)
|
||||
if (key == LogicalKeyboardKey.channelUp) {
|
||||
if (widget.streamType == model.StreamType.live && widget.channels != null) {
|
||||
if (widget.streamType == model.StreamType.live &&
|
||||
widget.channels != null) {
|
||||
_playNext();
|
||||
_onUserInteraction();
|
||||
return true;
|
||||
@@ -1522,9 +1529,11 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
LogicalKeyboardKey.arrowLeft) {
|
||||
final newPos =
|
||||
_position - const Duration(seconds: 10);
|
||||
_player.seek(newPos < Duration.zero
|
||||
? Duration.zero
|
||||
: newPos);
|
||||
_player.seek(
|
||||
newPos < Duration.zero
|
||||
? Duration.zero
|
||||
: newPos,
|
||||
);
|
||||
_resetControlsTimer();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
///
|
||||
/// Handles Xtream API communication with batching and caching optimizations
|
||||
/// for mobile platforms. Designed for performance with TiviMate-level efficiency.
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'dart:async';
|
||||
import '../models/xtream_models.dart' as model;
|
||||
import 'package:xtremobile/core/models/playlist_config.dart';
|
||||
import 'package:xtremobile/core/api/api_client.dart';
|
||||
|
||||
/// EPG cache entry with TTL
|
||||
class _EpgCacheEntry {
|
||||
@@ -18,7 +18,8 @@ class _EpgCacheEntry {
|
||||
|
||||
_EpgCacheEntry(this.data) : timestamp = DateTime.now();
|
||||
|
||||
bool get isExpired => DateTime.now().difference(timestamp).inSeconds > ttlSeconds;
|
||||
bool get isExpired =>
|
||||
DateTime.now().difference(timestamp).inSeconds > ttlSeconds;
|
||||
}
|
||||
|
||||
/// Xtream API Service for Mobile
|
||||
@@ -49,8 +50,18 @@ class XtreamServiceMobile {
|
||||
_username = config.username;
|
||||
_password = config.password;
|
||||
|
||||
// Use shared Dio instance from ApiClient (with DNS resolution, etc.)
|
||||
_dio = ApiClient().dio;
|
||||
// Create independent Dio for Xtream API (avoid ApiClient baseUrl issues)
|
||||
_dio = Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
sendTimeout: const Duration(seconds: 15),
|
||||
),
|
||||
);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('✅ [XtreamServiceMobile] Initialized with URL: $_baseUrl');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get live channels for a category (with batch EPG support)
|
||||
@@ -81,23 +92,31 @@ class XtreamServiceMobile {
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data is List) {
|
||||
final channels = (response.data as List)
|
||||
.map((e) => model.Channel.fromJson(e))
|
||||
.toList();
|
||||
if (response.statusCode == 200) {
|
||||
if (response.data is List) {
|
||||
final channels = (response.data as List)
|
||||
.map((e) => model.Channel.fromJson(e))
|
||||
.toList();
|
||||
|
||||
if (kDebugMode) {
|
||||
final categories = channels.map((c) => c.categoryName).toSet();
|
||||
print(
|
||||
'✅ Loaded ${channels.length} channels with ${categories.length} categories: $categories',
|
||||
);
|
||||
if (kDebugMode) {
|
||||
final categories = channels.map((c) => c.categoryName).toSet();
|
||||
print(
|
||||
'✅ Loaded ${channels.length} channels with ${categories.length} categories: $categories',
|
||||
);
|
||||
}
|
||||
|
||||
return channels;
|
||||
} else if (response.data is Map) {
|
||||
if (kDebugMode) print('⚠️ API returned a Map instead of List: ${response.data}');
|
||||
return [];
|
||||
} else {
|
||||
if (kDebugMode) print('⚠️ API returned unexpected type: ${response.data.runtimeType}');
|
||||
return [];
|
||||
}
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
print('⚠️ Unexpected response status: ${response.statusCode}');
|
||||
print('⚠️ Unexpected response status: ${response.statusCode}, body: ${response.data}');
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
@@ -110,12 +129,13 @@ class XtreamServiceMobile {
|
||||
Future<model.ShortEPG> getShortEPG(String streamId) async {
|
||||
// For backward compatibility, but prefer batch loading
|
||||
final batch = await getBatchEPG([streamId]);
|
||||
return batch[streamId] ?? model.ShortEPG(
|
||||
id: streamId,
|
||||
title: 'No Program',
|
||||
start: DateTime.now().toIso8601String(),
|
||||
end: DateTime.now().add(const Duration(hours: 1)).toIso8601String(),
|
||||
);
|
||||
return batch[streamId] ??
|
||||
model.ShortEPG(
|
||||
id: streamId,
|
||||
title: 'No Program',
|
||||
start: DateTime.now().toIso8601String(),
|
||||
end: DateTime.now().add(const Duration(hours: 1)).toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get SHORT EPG for MULTIPLE channels in ONE request
|
||||
@@ -123,7 +143,8 @@ class XtreamServiceMobile {
|
||||
/// This is the optimized method that prevents N+1 queries.
|
||||
/// Instead of 50 requests for 50 channels, this loads all at once.
|
||||
/// Results are cached for 1 hour to avoid repeated requests.
|
||||
Future<Map<String, model.ShortEPG>> getBatchEPG(List<String> streamIds) async {
|
||||
Future<Map<String, model.ShortEPG>> getBatchEPG(
|
||||
List<String> streamIds) async {
|
||||
if (streamIds.isEmpty) return {};
|
||||
|
||||
// Create cache key from sorted IDs for consistency
|
||||
@@ -133,7 +154,8 @@ class XtreamServiceMobile {
|
||||
// Check if we have valid cached data
|
||||
final cached = _epgBatchCache[cacheKeyStr];
|
||||
if (cached != null && !cached.isExpired) {
|
||||
if (kDebugMode) print('✅ EPG batch cache hit: ${streamIds.length} channels');
|
||||
if (kDebugMode)
|
||||
print('✅ EPG batch cache hit: ${streamIds.length} channels');
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
@@ -158,7 +180,9 @@ class XtreamServiceMobile {
|
||||
|
||||
/// Internal: Load EPG batch from API
|
||||
Future<Map<String, model.ShortEPG>> _loadEpgBatch(
|
||||
List<String> streamIds, String cacheKey) async {
|
||||
List<String> streamIds,
|
||||
String cacheKey,
|
||||
) async {
|
||||
try {
|
||||
final result = <String, model.ShortEPG>{};
|
||||
|
||||
@@ -186,18 +210,20 @@ class XtreamServiceMobile {
|
||||
if (item is Map && item.containsKey('stream_id')) {
|
||||
final streamId = item['stream_id'].toString();
|
||||
// Assuming the API returns a list of EPG entries for this stream
|
||||
if (item['epg_listings'] is List && (item['epg_listings'] as List).isNotEmpty) {
|
||||
final epgJson = (item['epg_listings'] as List).first;
|
||||
result[streamId] = model.ShortEPG.fromJson({
|
||||
...epgJson,
|
||||
'id': streamId,
|
||||
});
|
||||
if (item['epg_listings'] is List &&
|
||||
(item['epg_listings'] as List).isNotEmpty) {
|
||||
final epgJson = (item['epg_listings'] as List).first;
|
||||
result[streamId] = model.ShortEPG.fromJson({
|
||||
...epgJson,
|
||||
'id': streamId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
print('✅ Loaded EPG for ${result.length}/${streamIds.length} channels');
|
||||
print(
|
||||
'✅ Loaded EPG for ${result.length}/${streamIds.length} channels');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,25 +254,34 @@ class XtreamServiceMobile {
|
||||
)
|
||||
.timeout(const Duration(seconds: 8));
|
||||
|
||||
if (response.statusCode == 200 && response.data is List) {
|
||||
final categories = (response.data as List)
|
||||
.map((e) => model.Category.fromJson(e))
|
||||
.toList();
|
||||
if (response.statusCode == 200) {
|
||||
if (response.data is List) {
|
||||
final categories = (response.data as List)
|
||||
.map((e) => model.Category.fromJson(e))
|
||||
.toList();
|
||||
|
||||
if (kDebugMode) {
|
||||
print('✅ Loaded ${categories.length} live categories');
|
||||
if (kDebugMode) {
|
||||
print('✅ Loaded ${categories.length} live categories: ${categories.map((c) => c.categoryName).toList()}');
|
||||
}
|
||||
|
||||
return categories;
|
||||
} else if (response.data is Map) {
|
||||
if (kDebugMode) print('⚠️ get_live_categories returned a Map (not a list): ${response.data}');
|
||||
return [];
|
||||
} else {
|
||||
if (kDebugMode) print('⚠️ get_live_categories returned unexpected type: ${response.data.runtimeType}');
|
||||
return [];
|
||||
}
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
print('⚠️ Failed to load categories (status: ${response.statusCode})');
|
||||
print('⚠️ Failed to load categories (status: ${response.statusCode}, body: ${response.data})');
|
||||
}
|
||||
return [];
|
||||
} on TimeoutException {
|
||||
if (kDebugMode) {
|
||||
print('⏱️ Timeout loading live categories - will fallback to loading all channels');
|
||||
print(
|
||||
'⏱️ Timeout loading live categories - will fallback to loading all channels');
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
@@ -335,7 +370,9 @@ class XtreamServiceMobile {
|
||||
/// Search VOD movies
|
||||
Future<List<model.VodItem>> searchMovies(String query) async {
|
||||
final all = await getMovies();
|
||||
return all.where((m) => m.name.toLowerCase().contains(query.toLowerCase())).toList();
|
||||
return all
|
||||
.where((m) => m.name.toLowerCase().contains(query.toLowerCase()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Get all series
|
||||
@@ -363,7 +400,9 @@ class XtreamServiceMobile {
|
||||
/// Search series
|
||||
Future<List<model.Series>> searchSeries(String query) async {
|
||||
final all = await getSeries();
|
||||
return all.where((s) => s.name.toLowerCase().contains(query.toLowerCase())).toList();
|
||||
return all
|
||||
.where((s) => s.name.toLowerCase().contains(query.toLowerCase()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Get series paginated
|
||||
@@ -373,7 +412,7 @@ class XtreamServiceMobile {
|
||||
|
||||
/// Get series info
|
||||
Future<model.SeriesInfo?> getSeriesInfo(String seriesId) async {
|
||||
try {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'$_baseUrl/player_api.php',
|
||||
queryParameters: {
|
||||
@@ -387,7 +426,7 @@ class XtreamServiceMobile {
|
||||
if (response.statusCode == 200 && response.data is Map) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final seriesModel = model.Series.fromJson(data['info'] ?? {});
|
||||
|
||||
|
||||
final episodesMap = <String, List<model.Episode>>{};
|
||||
if (data['episodes'] is Map) {
|
||||
final seasons = data['episodes'] as Map<String, dynamic>;
|
||||
@@ -399,7 +438,7 @@ class XtreamServiceMobile {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return model.SeriesInfo(
|
||||
series: seriesModel,
|
||||
episodes: episodesMap,
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:xtremobile/core/utils/image_cache_config.dart';
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
|
||||
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
|
||||
@@ -11,8 +10,6 @@ import 'package:xtremobile/features/iptv/screens/native_player_screen.dart';
|
||||
import 'package:xtremobile/core/models/iptv_models.dart';
|
||||
import 'package:xtremobile/core/models/playlist_config.dart';
|
||||
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||
import 'package:xtremobile/core/api/dns_resolver.dart';
|
||||
import 'package:xtremobile/mobile/theme/mobile_theme.dart';
|
||||
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
|
||||
import 'package:xtremobile/features/iptv/screens/lite_player_screen.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -158,7 +155,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
||||
),
|
||||
data: (groupedChannels) {
|
||||
// Prepare categories
|
||||
var categories = groupedChannels.map((c) => c.categoryName).toSet().toList();
|
||||
var categories =
|
||||
groupedChannels.map((c) => c.categoryName).toSet().toList();
|
||||
if (settings.liveTvKeywords.isNotEmpty) {
|
||||
categories = categories
|
||||
.where((cat) => settings.matchesLiveTvFilter(cat))
|
||||
@@ -198,8 +196,9 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
||||
.where((c) => favorites.contains(c.streamId))
|
||||
.toList();
|
||||
} else if (uiState.selectedCategory != null) {
|
||||
displayedChannels =
|
||||
groupedChannels.where((c) => c.categoryName == uiState.selectedCategory).toList();
|
||||
displayedChannels = groupedChannels
|
||||
.where((c) => c.categoryName == uiState.selectedCategory)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,9 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
|
||||
final service =
|
||||
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
|
||||
final movies = await service.getMovies();
|
||||
final results = movies.where((m) => m.name.toLowerCase().contains(query.toLowerCase())).toList();
|
||||
final results = movies
|
||||
.where((m) => m.name.toLowerCase().contains(query.toLowerCase()))
|
||||
.toList();
|
||||
|
||||
if (mounted && _searchQuery == query) {
|
||||
setState(() {
|
||||
@@ -110,7 +112,9 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_categories = categories.map((c) => MapEntry(c.categoryId, c.categoryName)).toList()
|
||||
_categories = categories
|
||||
.map((c) => MapEntry(c.categoryId, c.categoryName))
|
||||
.toList()
|
||||
..sort((a, b) => a.value.compareTo(b.value));
|
||||
_isLoading = false;
|
||||
});
|
||||
@@ -400,7 +404,8 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
|
||||
focusedBorder: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.only(bottom: 11),
|
||||
contentPadding:
|
||||
const EdgeInsets.only(bottom: 11),
|
||||
),
|
||||
onChanged: _onSearchChanged,
|
||||
onSubmitted: (_) =>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
// import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
|
||||
|
||||
Reference in new issue
Block a user