Initial commit

This commit is contained in:
2025-07-13 14:01:29 +03:00
commit 0eaf91561a
188 changed files with 11616 additions and 0 deletions

View File

@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:neomovies_mobile/data/models/user.dart';
import 'package:neomovies_mobile/data/repositories/auth_repository.dart';
import 'package:neomovies_mobile/data/exceptions/auth_exceptions.dart';
enum AuthState { initial, loading, authenticated, unauthenticated, error }
class AuthProvider extends ChangeNotifier {
AuthProvider({required AuthRepository authRepository})
: _authRepository = authRepository;
final AuthRepository _authRepository;
AuthState _state = AuthState.initial;
AuthState get state => _state;
String? _token;
String? get token => _token;
// Считаем пользователя аутентифицированным, если состояние AuthState.authenticated
bool get isAuthenticated => _state == AuthState.authenticated;
User? _user;
User? get user => _user;
String? _error;
String? get error => _error;
bool _needsVerification = false;
bool get needsVerification => _needsVerification;
String? _pendingEmail;
String? get pendingEmail => _pendingEmail;
Future<void> checkAuthStatus() async {
_state = AuthState.loading;
notifyListeners();
try {
final isLoggedIn = await _authRepository.isLoggedIn();
if (isLoggedIn) {
_user = await _authRepository.getCurrentUser();
_state = AuthState.authenticated;
} else {
_state = AuthState.unauthenticated;
}
} catch (e) {
_state = AuthState.unauthenticated;
}
notifyListeners();
}
Future<void> login(String email, String password) async {
_state = AuthState.loading;
_error = null;
_needsVerification = false;
notifyListeners();
try {
await _authRepository.login(email, password);
_user = await _authRepository.getCurrentUser();
_state = AuthState.authenticated;
} catch (e) {
if (e is UnverifiedAccountException) {
// Need verification flow
_needsVerification = true;
_pendingEmail = e.email;
_state = AuthState.unauthenticated;
} else {
_error = e.toString();
_state = AuthState.error;
}
}
notifyListeners();
}
Future<void> register(String name, String email, String password) async {
_state = AuthState.loading;
_error = null;
notifyListeners();
try {
await _authRepository.register(name, email, password);
// After registration, user needs to verify, so we go to unauthenticated state
// The UI will navigate to the verify screen
_state = AuthState.unauthenticated;
} catch (e) {
_error = e.toString();
_state = AuthState.error;
}
notifyListeners();
}
Future<void> verifyEmail(String email, String code) async {
_state = AuthState.loading;
_error = null;
notifyListeners();
try {
await _authRepository.verifyEmail(email, code);
// After verification, user should log in.
// For a better UX, we could auto-login them, but for now, we'll just go to the unauthenticated state.
_state = AuthState.unauthenticated;
} catch (e) {
_error = e.toString();
_state = AuthState.error;
}
notifyListeners();
}
Future<void> logout() async {
_state = AuthState.loading;
notifyListeners();
await _authRepository.logout();
_user = null;
_state = AuthState.unauthenticated;
notifyListeners();
}
Future<void> deleteAccount() async {
_state = AuthState.loading;
notifyListeners();
try {
await _authRepository.deleteAccount();
_user = null;
_state = AuthState.unauthenticated;
} catch (e) {
_error = e.toString();
_state = AuthState.error;
}
notifyListeners();
}
/// Reset pending verification state after navigating to VerifyScreen
void clearVerificationFlag() {
_needsVerification = false;
_pendingEmail = null;
notifyListeners();
}
}

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:neomovies_mobile/data/models/favorite.dart';
import 'package:neomovies_mobile/data/models/movie.dart';
import 'package:neomovies_mobile/data/repositories/favorites_repository.dart';
import 'package:neomovies_mobile/presentation/providers/auth_provider.dart';
class FavoritesProvider extends ChangeNotifier {
final FavoritesRepository _favoritesRepository;
AuthProvider _authProvider;
List<Favorite> _favorites = [];
bool _isLoading = false;
String? _error;
List<Favorite> get favorites => _favorites;
bool get isLoading => _isLoading;
String? get error => _error;
FavoritesProvider(this._favoritesRepository, this._authProvider) {
// Listen for authentication state changes
_authProvider.addListener(_onAuthStateChanged);
_onAuthStateChanged();
}
void update(AuthProvider authProvider) {
// Remove listener from previous AuthProvider to avoid leaks
_authProvider.removeListener(_onAuthStateChanged);
_authProvider = authProvider;
_authProvider.addListener(_onAuthStateChanged);
_onAuthStateChanged();
}
void _onAuthStateChanged() {
if (_authProvider.isAuthenticated) {
fetchFavorites();
} else {
_clearFavorites();
}
}
Future<void> fetchFavorites() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_favorites = await _favoritesRepository.getFavorites();
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> addFavorite(Movie movie) async {
try {
await _favoritesRepository.addFavorite(
movie.id.toString(),
'movie', // Assuming mediaType is 'movie'
movie.title,
movie.posterPath ?? '',
);
await fetchFavorites(); // Refresh the list
} catch (e) {
_error = e.toString();
notifyListeners();
}
}
Future<void> removeFavorite(String mediaId) async {
try {
await _favoritesRepository.removeFavorite(mediaId);
_favorites.removeWhere((fav) => fav.mediaId == mediaId);
notifyListeners();
} catch (e) {
_error = e.toString();
notifyListeners();
}
}
bool isFavorite(String mediaId) {
return _favorites.any((fav) => fav.mediaId == mediaId);
}
void _clearFavorites() {
_favorites = [];
_error = null;
_isLoading = false;
notifyListeners();
}
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:neomovies_mobile/data/models/movie.dart';
import 'package:neomovies_mobile/data/repositories/movie_repository.dart';
enum ViewState { idle, loading, success, error }
class HomeProvider extends ChangeNotifier {
final MovieRepository _movieRepository;
HomeProvider({required MovieRepository movieRepository})
: _movieRepository = movieRepository;
List<Movie> _popularMovies = [];
List<Movie> get popularMovies => _popularMovies;
List<Movie> _topRatedMovies = [];
List<Movie> get topRatedMovies => _topRatedMovies;
List<Movie> _upcomingMovies = [];
List<Movie> get upcomingMovies => _upcomingMovies;
bool _isLoading = false;
bool get isLoading => _isLoading;
String? _errorMessage;
String? get errorMessage => _errorMessage;
// Initial fetch
void init() {
fetchAllMovies();
}
Future<void> fetchAllMovies() async {
_isLoading = true;
_errorMessage = null;
// Notify listeners only for the initial loading state
if (_popularMovies.isEmpty) {
notifyListeners();
}
try {
final results = await Future.wait([
_movieRepository.getPopularMovies(),
_movieRepository.getTopRatedMovies(),
_movieRepository.getUpcomingMovies(),
]);
_popularMovies = results[0];
_topRatedMovies = results[1];
_upcomingMovies = results[2];
} catch (e) {
_errorMessage = 'Failed to fetch movies: ${e.toString()}';
} finally {
_isLoading = false;
notifyListeners();
}
}
}

View File

@@ -0,0 +1,254 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:yaml/yaml.dart';
import '../../data/models/library_license.dart';
const Map<String, String> _licenseOverrides = {
'archive': 'MIT',
'args': 'BSD-3-Clause',
'async': 'BSD-3-Clause',
'boolean_selector': 'BSD-3-Clause',
'characters': 'BSD-3-Clause',
'clock': 'Apache-2.0',
'collection': 'BSD-3-Clause',
'convert': 'BSD-3-Clause',
'crypto': 'BSD-3-Clause',
'cupertino_icons': 'MIT',
'dbus': 'MIT',
'fake_async': 'Apache-2.0',
'file': 'Apache-2.0',
'flutter_lints': 'BSD-3-Clause',
'flutter_secure_storage_linux': 'BSD-3-Clause',
'flutter_secure_storage_macos': 'BSD-3-Clause',
'flutter_secure_storage_platform_interface': 'BSD-3-Clause',
'flutter_secure_storage_web': 'BSD-3-Clause',
'flutter_secure_storage_windows': 'BSD-3-Clause',
'http_parser': 'BSD-3-Clause',
'intl': 'BSD-3-Clause',
'js': 'BSD-3-Clause',
'leak_tracker': 'BSD-3-Clause',
'lints': 'BSD-3-Clause',
'matcher': 'BSD-3-Clause',
'material_color_utilities': 'BSD-3-Clause',
'meta': 'BSD-3-Clause',
'petitparser': 'MIT',
'platform': 'BSD-3-Clause',
'plugin_platform_interface': 'BSD-3-Clause',
'pool': 'BSD-3-Clause',
'posix': 'MIT',
'source_span': 'BSD-3-Clause',
'stack_trace': 'BSD-3-Clause',
'stream_channel': 'BSD-3-Clause',
'string_scanner': 'BSD-3-Clause',
'term_glyph': 'BSD-3-Clause',
'test_api': 'BSD-3-Clause',
'typed_data': 'BSD-3-Clause',
'uuid': 'MIT',
'vector_math': 'BSD-3-Clause',
'vm_service': 'BSD-3-Clause',
'win32': 'BSD-3-Clause',
'xdg_directories': 'MIT',
'xml': 'MIT',
'yaml': 'MIT',
};
class LicensesProvider with ChangeNotifier {
final ValueNotifier<List<LibraryLicense>> _licenses = ValueNotifier([]);
final ValueNotifier<bool> _isLoading = ValueNotifier(false);
final ValueNotifier<String?> _error = ValueNotifier(null);
LicensesProvider() {
loadLicenses();
}
ValueNotifier<List<LibraryLicense>> get licenses => _licenses;
ValueNotifier<bool> get isLoading => _isLoading;
ValueNotifier<String?> get error => _error;
Future<void> loadLicenses({bool forceRefresh = false}) async {
_isLoading.value = true;
_error.value = null;
try {
final cachedLicenses = await _loadFromCache();
if (cachedLicenses != null && !forceRefresh) {
_licenses.value = cachedLicenses;
// Still trigger background update for licenses that were loading or failed
final toUpdate = cachedLicenses.where((l) => l.license == 'loading...' || l.license == 'unknown').toList();
if (toUpdate.isNotEmpty) {
_fetchFullLicenseInfo(toUpdate);
}
} else {
_licenses.value = await _fetchInitialLicenses();
_fetchFullLicenseInfo(_licenses.value.where((l) => l.license == 'loading...').toList());
}
} catch (e) {
_error.value = 'Failed to load licenses: $e';
}
_isLoading.value = false;
}
Future<List<LibraryLicense>?> _loadFromCache() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString('licenses_cache');
if (jsonStr != null) {
final List<dynamic> jsonList = jsonDecode(jsonStr);
return jsonList.map((e) => LibraryLicense.fromMap(e)).toList();
}
} catch (_) {}
return null;
}
Future<List<LibraryLicense>> _fetchInitialLicenses() async {
final result = <LibraryLicense>[];
try {
final lockFileContent = await rootBundle.loadString('pubspec.lock');
final doc = loadYaml(lockFileContent);
final packages = doc['packages'] as YamlMap;
final pubspecContent = await rootBundle.loadString('pubspec.yaml');
final pubspec = loadYaml(pubspecContent);
result.add(LibraryLicense(
name: pubspec['name'],
version: pubspec['version'],
license: 'Apache 2.0',
url: 'https://gitlab.com/foxixius/neomovies_mobile',
description: pubspec['description'],
));
for (final key in packages.keys) {
final name = key.toString();
final package = packages[key];
if (package['source'] != 'hosted') continue;
final version = package['version'].toString();
result.add(LibraryLicense(
name: name,
version: version,
license: 'loading...',
url: 'https://pub.dev/packages/$name',
description: '',
));
}
} catch (e) {
_error.value = 'Failed to load initial license list: $e';
}
return result;
}
void _fetchFullLicenseInfo(List<LibraryLicense> toFetch) async {
final futures = toFetch.map((lib) async {
try {
final url = 'https://pub.dev/api/packages/${lib.name}';
final resp = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 5));
if (resp.statusCode == 200) {
final data = jsonDecode(resp.body) as Map<String, dynamic>;
final pubspec = data['latest']['pubspec'] as Map<String, dynamic>;
String licenseType = (pubspec['license'] ?? 'unknown').toString();
if (licenseType == 'unknown' && _licenseOverrides.containsKey(lib.name)) {
licenseType = _licenseOverrides[lib.name]!;
}
final repoUrl = (pubspec['repository'] ?? pubspec['homepage'] ?? 'https://pub.dev/packages/${lib.name}').toString();
final description = (pubspec['description'] ?? '').toString();
return lib.copyWith(license: licenseType, url: repoUrl, description: description);
}
} catch (_) {}
return lib.copyWith(license: 'unknown');
}).toList();
final updatedLicenses = await Future.wait(futures);
final currentList = List<LibraryLicense>.from(_licenses.value);
bool hasChanged = false;
for (final updated in updatedLicenses) {
final index = currentList.indexWhere((e) => e.name == updated.name);
if (index != -1 && currentList[index].license != updated.license) {
currentList[index] = updated;
hasChanged = true;
}
}
if (hasChanged) {
_licenses.value = currentList;
_saveToCache(currentList);
}
}
Future<String> fetchLicenseText(LibraryLicense library) async {
if (library.licenseText != null) return library.licenseText!;
final cached = (await _loadFromCache())?.firstWhere((e) => e.name == library.name, orElse: () => library);
if (cached?.licenseText != null) {
return cached!.licenseText!;
}
try {
final text = await _fetchLicenseTextFromRepo(library.url);
if (text != null) {
final updatedLibrary = library.copyWith(licenseText: text);
final currentList = List<LibraryLicense>.from(_licenses.value);
final index = currentList.indexWhere((e) => e.name == library.name);
if (index != -1) {
currentList[index] = updatedLibrary;
_licenses.value = currentList;
_saveToCache(currentList);
}
return text;
}
} catch (_) {}
return library.license;
}
Future<String?> _fetchLicenseTextFromRepo(String repoUrl) async {
try {
final uri = Uri.parse(repoUrl);
final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList();
if (segments.length < 2) return null;
final author = segments[0];
final repo = segments[1].replaceAll('.git', '');
final branches = ['main', 'master', 'HEAD']; // Common branch names
final filenames = ['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'LICENSE-2.0.txt']; // Common license filenames
String? rawUrlBase;
if (repoUrl.contains('github.com')) {
rawUrlBase = 'https://raw.githubusercontent.com/$author/$repo';
} else if (repoUrl.contains('gitlab.com')) {
rawUrlBase = 'https://gitlab.com/$author/$repo/-/raw';
} else {
return null; // Unsupported provider
}
for (final branch in branches) {
for (final filename in filenames) {
final url = '$rawUrlBase/$branch/$filename';
try {
final resp = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 5));
if (resp.statusCode == 200 && resp.body.isNotEmpty) {
return resp.body;
}
} catch (_) {
// Ignore timeout or other errors and try next candidate
}
}
}
} catch (_) {}
return null;
}
Future<void> _saveToCache(List<LibraryLicense> licenses) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = jsonEncode(licenses.map((e) => e.toMap()).toList());
await prefs.setString('licenses_cache_v2', jsonStr);
} catch (_) {}
}
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:neomovies_mobile/data/models/movie.dart';
import 'package:neomovies_mobile/data/repositories/movie_repository.dart';
import 'package:neomovies_mobile/data/api/api_client.dart';
class MovieDetailProvider with ChangeNotifier {
final MovieRepository _movieRepository;
final ApiClient _apiClient;
MovieDetailProvider(this._movieRepository, this._apiClient);
bool _isLoading = false;
bool get isLoading => _isLoading;
bool _isImdbLoading = false;
bool get isImdbLoading => _isImdbLoading;
Movie? _movie;
Movie? get movie => _movie;
String? _imdbId;
String? get imdbId => _imdbId;
String? _error;
String? get error => _error;
Future<void> loadMedia(int mediaId, String mediaType) async {
_isLoading = true;
_isImdbLoading = true;
_error = null;
_movie = null;
_imdbId = null;
notifyListeners();
try {
if (mediaType == 'movie') {
_movie = await _movieRepository.getMovieById(mediaId.toString());
} else {
_movie = await _movieRepository.getTvById(mediaId.toString());
}
_isLoading = false;
notifyListeners();
if (_movie != null) {
_imdbId = await _apiClient.getImdbId(mediaId, mediaType);
}
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
_isImdbLoading = false;
notifyListeners();
}
}
// Backward compatibility
Future<void> loadMovie(int movieId) async {
await loadMedia(movieId, 'movie');
}
}

View File

@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:neomovies_mobile/data/models/movie.dart';
import 'package:neomovies_mobile/data/repositories/movie_repository.dart';
// Enum to define the category of movies to fetch
enum MovieCategory { popular, topRated, upcoming }
class MovieListProvider extends ChangeNotifier {
final MovieRepository _movieRepository;
final MovieCategory category;
MovieListProvider({
required this.category,
required MovieRepository movieRepository,
}) : _movieRepository = movieRepository;
List<Movie> _movies = [];
List<Movie> get movies => _movies;
int _currentPage = 1;
bool _isLoading = false;
bool _isLoadingMore = false;
bool _hasMore = true;
String? _errorMessage;
bool get isLoading => _isLoading;
bool get isLoadingMore => _isLoadingMore;
String? get errorMessage => _errorMessage;
Future<void> fetchInitialMovies() async {
if (_isLoading) return;
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
final newMovies = await _fetchMoviesForCategory(page: 1);
_movies = newMovies;
_currentPage = 1;
_hasMore = newMovies.isNotEmpty;
} catch (e) {
_errorMessage = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> fetchNextPage() async {
if (_isLoadingMore || !_hasMore) return;
_isLoadingMore = true;
notifyListeners();
try {
final newMovies = await _fetchMoviesForCategory(page: _currentPage + 1);
_movies.addAll(newMovies);
_currentPage++;
_hasMore = newMovies.isNotEmpty;
} catch (e) {
// Optionally handle error for pagination differently
_errorMessage = e.toString();
} finally {
_isLoadingMore = false;
notifyListeners();
}
}
Future<List<Movie>> _fetchMoviesForCategory({required int page}) {
switch (category) {
case MovieCategory.popular:
return _movieRepository.getPopularMovies(page: page);
case MovieCategory.topRated:
return _movieRepository.getTopRatedMovies(page: page);
case MovieCategory.upcoming:
return _movieRepository.getUpcomingMovies(page: page);
}
}
String getTitle() {
switch (category) {
case MovieCategory.popular:
return 'Popular Movies';
case MovieCategory.topRated:
return 'Top Rated Movies';
case MovieCategory.upcoming:
return 'Latest Movies';
}
}
}

View File

@@ -0,0 +1,368 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import 'package:chewie/chewie.dart';
import 'package:neomovies_mobile/data/models/player/video_source.dart';
import 'package:neomovies_mobile/data/models/player/video_quality.dart';
import 'package:neomovies_mobile/data/models/player/audio_track.dart';
import 'package:neomovies_mobile/data/models/player/subtitle.dart';
import 'package:neomovies_mobile/data/models/player/player_settings.dart';
class PlayerProvider with ChangeNotifier {
// Controller instances
VideoPlayerController? _videoPlayerController;
ChewieController? _chewieController;
// Player state
bool _isInitialized = false;
bool _isPlaying = false;
bool _isBuffering = false;
bool _isFullScreen = false;
bool _showControls = true;
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
// Media info
String? _mediaId;
String? _mediaType;
String? _title;
String? _subtitle;
String? _posterUrl;
// Player settings
PlayerSettings _settings;
// Available options
List<VideoSource> _sources = [];
List<VideoQuality> _qualities = [];
List<AudioTrack> _audioTracks = [];
List<Subtitle> _subtitles = [];
// Selected options
VideoSource? _selectedSource;
VideoQuality? _selectedQuality;
AudioTrack? _selectedAudioTrack;
Subtitle? _selectedSubtitle;
// Playback state
double _volume = 1.0;
bool _isMuted = false;
double _playbackSpeed = 1.0;
// Getters
bool get isInitialized => _isInitialized;
bool get isPlaying => _isPlaying;
bool get isBuffering => _isBuffering;
bool get isFullScreen => _isFullScreen;
bool get showControls => _showControls;
Duration get position => _position;
Duration get duration => _duration;
String? get mediaId => _mediaId;
String? get mediaType => _mediaType;
String? get title => _title;
String? get subtitle => _subtitle;
String? get posterUrl => _posterUrl;
PlayerSettings get settings => _settings;
List<VideoSource> get sources => _sources;
List<VideoQuality> get qualities => _qualities;
List<AudioTrack> get audioTracks => _audioTracks;
List<Subtitle> get subtitles => _subtitles;
VideoSource? get selectedSource => _selectedSource;
VideoQuality? get selectedQuality => _selectedQuality;
AudioTrack? get selectedAudioTrack => _selectedAudioTrack;
Subtitle? get selectedSubtitle => _selectedSubtitle;
double get volume => _volume;
bool get isMuted => _isMuted;
double get playbackSpeed => _playbackSpeed;
// Controllers
VideoPlayerController? get videoPlayerController => _videoPlayerController;
ChewieController? get chewieController => _chewieController;
// Constructor
PlayerProvider({PlayerSettings? initialSettings})
: _settings = initialSettings ?? PlayerSettings.defaultSettings();
// Initialize the player with media
Future<void> initialize({
required String mediaId,
required String mediaType,
String? title,
String? subtitle,
String? posterUrl,
List<VideoSource>? sources,
List<VideoQuality>? qualities,
List<AudioTrack>? audioTracks,
List<Subtitle>? subtitles,
}) async {
_mediaId = mediaId;
_mediaType = mediaType;
_title = title;
_subtitle = subtitle;
_posterUrl = posterUrl;
// Set available options
_sources = sources ?? [];
_qualities = qualities ?? VideoQuality.defaultQualities;
_audioTracks = audioTracks ?? [];
_subtitles = subtitles ?? [];
// Set default selections
_selectedSource = _sources.isNotEmpty ? _sources.first : null;
_selectedQuality = _qualities.isNotEmpty ? _qualities.first : null;
_selectedAudioTrack = _audioTracks.isNotEmpty ? _audioTracks.first : null;
_selectedSubtitle = _subtitles.firstWhere(
(s) => s.id == 'none',
orElse: () => _subtitles.first,
);
// Initialize video player with the first source and quality
if (_selectedSource != null && _selectedQuality != null) {
await _initializeVideoPlayer();
}
_isInitialized = true;
notifyListeners();
}
// Initialize video player with current source and quality
Future<void> _initializeVideoPlayer() async {
if (_selectedSource == null || _selectedQuality == null) return;
// Dispose of previous controllers if they exist
await dispose();
try {
// In a real app, you would fetch the actual video URL based on source and quality
final videoUrl = _getVideoUrl(_selectedSource!, _selectedQuality!);
_videoPlayerController = VideoPlayerController.networkUrl(
Uri.parse(videoUrl),
videoPlayerOptions: VideoPlayerOptions(
mixWithOthers: true,
),
);
await _videoPlayerController!.initialize();
// Setup position listener
_videoPlayerController!.addListener(_videoPlayerListener);
// Setup chewie controller
_setupChewieController();
// Start playing if autoplay is enabled
if (_settings.autoPlay) {
await _videoPlayerController!.play();
_isPlaying = true;
}
notifyListeners();
} catch (e) {
debugPrint('Error initializing video player: $e');
// Handle error appropriately
rethrow;
}
}
// Setup Chewie controller with custom options
void _setupChewieController() {
_chewieController = ChewieController(
videoPlayerController: _videoPlayerController!,
autoPlay: _settings.autoPlay,
looping: false,
allowFullScreen: true,
allowMuting: true,
allowPlaybackSpeedChanging: true,
showControls: _settings.showControlsOnStart,
showControlsOnInitialize: _settings.showControlsOnStart,
placeholder: _posterUrl != null ? Image.network(_posterUrl!) : null,
aspectRatio: _videoPlayerController!.value.aspectRatio,
// Custom options can be added here
);
// Listen to Chewie events
_chewieController!.addListener(() {
if (_chewieController!.isFullScreen != _isFullScreen) {
_isFullScreen = _chewieController!.isFullScreen;
notifyListeners();
}
if (_chewieController!.isPlaying != _isPlaying) {
_isPlaying = _chewieController!.isPlaying;
notifyListeners();
}
});
}
// Video player listener
void _videoPlayerListener() {
if (!_videoPlayerController!.value.isInitialized) return;
final controller = _videoPlayerController!;
// Update buffering state
final isBuffering = controller.value.isBuffering;
if (_isBuffering != isBuffering) {
_isBuffering = isBuffering;
notifyListeners();
}
// Update position and duration
if (controller.value.duration != _duration) {
_duration = controller.value.duration;
}
if (controller.value.position != _position) {
_position = controller.value.position;
notifyListeners();
}
}
// Get video URL based on source and quality
// In a real app, this would make an API call to get the stream URL
String _getVideoUrl(VideoSource source, VideoQuality quality) {
// This is a placeholder - replace with actual logic to get the video URL
return 'https://example.com/stream/$mediaType/$mediaId?source=${source.name.toLowerCase()}&quality=${quality.name}';
}
// Toggle play/pause
Future<void> togglePlayPause() async {
if (_videoPlayerController == null) return;
if (_isPlaying) {
await _videoPlayerController!.pause();
} else {
await _videoPlayerController!.play();
}
_isPlaying = !_isPlaying;
notifyListeners();
}
// Seek to a specific position
Future<void> seekTo(Duration position) async {
if (_videoPlayerController == null) return;
await _videoPlayerController!.seekTo(position);
_position = position;
notifyListeners();
}
// Set volume (0.0 to 1.0)
Future<void> setVolume(double volume) async {
if (_videoPlayerController == null) return;
_volume = volume.clamp(0.0, 1.0);
await _videoPlayerController!.setVolume(_isMuted ? 0.0 : _volume);
notifyListeners();
}
// Toggle mute
Future<void> toggleMute() async {
if (_videoPlayerController == null) return;
_isMuted = !_isMuted;
await _videoPlayerController!.setVolume(_isMuted ? 0.0 : _volume);
notifyListeners();
}
// Set playback speed
Future<void> setPlaybackSpeed(double speed) async {
if (_videoPlayerController == null) return;
_playbackSpeed = speed;
await _videoPlayerController!.setPlaybackSpeed(speed);
notifyListeners();
}
// Change video source
Future<void> setSource(VideoSource source) async {
if (_selectedSource == source) return;
_selectedSource = source;
await _initializeVideoPlayer();
notifyListeners();
}
// Change video quality
Future<void> setQuality(VideoQuality quality) async {
if (_selectedQuality == quality) return;
_selectedQuality = quality;
await _initializeVideoPlayer();
notifyListeners();
}
// Change audio track
void setAudioTrack(AudioTrack track) {
if (_selectedAudioTrack == track) return;
_selectedAudioTrack = track;
// In a real implementation, you would update the audio track on the video player
notifyListeners();
}
// Change subtitle
void setSubtitle(Subtitle subtitle) {
if (_selectedSubtitle == subtitle) return;
_selectedSubtitle = subtitle;
// In a real implementation, you would update the subtitle on the video player
notifyListeners();
}
// Toggle fullscreen
void toggleFullScreen() {
if (_chewieController == null) return;
_isFullScreen = !_isFullScreen;
if (_isFullScreen) {
_chewieController!.enterFullScreen();
} else {
_chewieController!.exitFullScreen();
}
notifyListeners();
}
// Toggle controls visibility
void toggleControls() {
_showControls = !_showControls;
notifyListeners();
}
// Update player settings
void updateSettings(PlayerSettings newSettings) {
_settings = newSettings;
// Apply settings that affect the current playback
if (_videoPlayerController != null) {
_videoPlayerController!.setPlaybackSpeed(_settings.playbackSpeed);
// Apply other settings as needed
}
notifyListeners();
}
// Clean up resources
@override
Future<void> dispose() async {
_videoPlayerController?.removeListener(_videoPlayerListener);
await _videoPlayerController?.dispose();
await _chewieController?.dispose();
_videoPlayerController = null;
_chewieController = null;
_isInitialized = false;
_isPlaying = false;
_isBuffering = false;
_isFullScreen = false;
_position = Duration.zero;
_duration = Duration.zero;
super.dispose();
}
}

View File

@@ -0,0 +1,212 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:neomovies_mobile/data/models/player/video_source.dart';
class SettingsProvider with ChangeNotifier {
static const String _settingsKey = 'player_settings';
late PlayerSettings _settings;
final SharedPreferences _prefs;
SettingsProvider(this._prefs) {
// Load settings from shared preferences
_loadSettings();
}
PlayerSettings get settings => _settings;
// Load settings from shared preferences
void _loadSettings() {
try {
final settingsJson = _prefs.getString(_settingsKey);
if (settingsJson != null) {
_settings = PlayerSettings.fromMap(
Map<String, dynamic>.from(settingsJson as Map),
);
} else {
// Use default settings if no saved settings exist
_settings = PlayerSettings.defaultSettings();
// Save default settings
_saveSettings();
}
} catch (e) {
debugPrint('Error loading player settings: $e');
// Fallback to default settings on error
_settings = PlayerSettings.defaultSettings();
}
}
// Save settings to shared preferences
Future<void> _saveSettings() async {
try {
await _prefs.setString(
_settingsKey,
_settings.toMap().toString(),
);
} catch (e) {
debugPrint('Error saving player settings: $e');
}
}
// Update and save settings
Future<void> updateSettings(PlayerSettings newSettings) async {
if (_settings == newSettings) return;
_settings = newSettings;
await _saveSettings();
notifyListeners();
}
// Individual setting updates
// Video settings
Future<void> setAutoPlay(bool value) async {
if (_settings.autoPlay == value) return;
_settings = _settings.copyWith(autoPlay: value);
await _saveSettings();
notifyListeners();
}
Future<void> setAutoPlayNextEpisode(bool value) async {
if (_settings.autoPlayNextEpisode == value) return;
_settings = _settings.copyWith(autoPlayNextEpisode: value);
await _saveSettings();
notifyListeners();
}
Future<void> setSkipIntro(bool value) async {
if (_settings.skipIntro == value) return;
_settings = _settings.copyWith(skipIntro: value);
await _saveSettings();
notifyListeners();
}
Future<void> setSkipCredits(bool value) async {
if (_settings.skipCredits == value) return;
_settings = _settings.copyWith(skipCredits: value);
await _saveSettings();
notifyListeners();
}
Future<void> setRememberPlaybackPosition(bool value) async {
if (_settings.rememberPlaybackPosition == value) return;
_settings = _settings.copyWith(rememberPlaybackPosition: value);
await _saveSettings();
notifyListeners();
}
Future<void> setPlaybackSpeed(double value) async {
if (_settings.playbackSpeed == value) return;
_settings = _settings.copyWith(playbackSpeed: value);
await _saveSettings();
notifyListeners();
}
// Subtitle settings
Future<void> setDefaultSubtitleLanguage(String language) async {
if (_settings.defaultSubtitleLanguage == language) return;
_settings = _settings.copyWith(defaultSubtitleLanguage: language);
await _saveSettings();
notifyListeners();
}
Future<void> setSubtitleSize(double size) async {
if (_settings.subtitleSize == size) return;
_settings = _settings.copyWith(subtitleSize: size);
await _saveSettings();
notifyListeners();
}
Future<void> setSubtitleTextColor(String color) async {
if (_settings.subtitleTextColor == color) return;
_settings = _settings.copyWith(subtitleTextColor: color);
await _saveSettings();
notifyListeners();
}
Future<void> setSubtitleBackgroundColor(String color) async {
if (_settings.subtitleBackgroundColor == color) return;
_settings = _settings.copyWith(subtitleBackgroundColor: color);
await _saveSettings();
notifyListeners();
}
Future<void> setSubtitleBackgroundEnabled(bool enabled) async {
if (_settings.subtitleBackgroundEnabled == enabled) return;
_settings = _settings.copyWith(subtitleBackgroundEnabled: enabled);
await _saveSettings();
notifyListeners();
}
// Playback settings
Future<void> setDefaultQualityIndex(int index) async {
if (_settings.defaultQualityIndex == index) return;
_settings = _settings.copyWith(defaultQualityIndex: index);
await _saveSettings();
notifyListeners();
}
Future<void> setDataSaverMode(bool enabled) async {
if (_settings.dataSaverMode == enabled) return;
_settings = _settings.copyWith(dataSaverMode: enabled);
await _saveSettings();
notifyListeners();
}
Future<void> setDownloadOverWifiOnly(bool enabled) async {
if (_settings.downloadOverWifiOnly == enabled) return;
_settings = _settings.copyWith(downloadOverWifiOnly: enabled);
await _saveSettings();
notifyListeners();
}
// Player UI settings
Future<void> setShowControlsOnStart(bool show) async {
if (_settings.showControlsOnStart == show) return;
_settings = _settings.copyWith(showControlsOnStart: show);
await _saveSettings();
notifyListeners();
}
Future<void> setDoubleTapToSeek(bool enabled) async {
if (_settings.doubleTapToSeek == enabled) return;
_settings = _settings.copyWith(doubleTapToSeek: enabled);
await _saveSettings();
notifyListeners();
}
Future<void> setSwipeToSeek(bool enabled) async {
if (_settings.swipeToSeek == enabled) return;
_settings = _settings.copyWith(swipeToSeek: enabled);
await _saveSettings();
notifyListeners();
}
Future<void> setShowRemainingTime(bool show) async {
if (_settings.showRemainingTime == show) return;
_settings = _settings.copyWith(showRemainingTime: show);
await _saveSettings();
notifyListeners();
}
// Default video source
Future<void> setDefaultSource(VideoSource source) async {
_settings = _settings.copyWith(defaultSource: source);
await _saveSettings();
notifyListeners();
}
// Reset all settings to default
Future<void> resetToDefaults() async {
_settings = PlayerSettings.defaultSettings();
await _saveSettings();
notifyListeners();
}
// Clear all settings
Future<void> clear() async {
await _prefs.remove(_settingsKey);
_settings = PlayerSettings.defaultSettings();
notifyListeners();
}
}

View File

@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:neomovies_mobile/data/repositories/reactions_repository.dart';
import 'package:neomovies_mobile/presentation/providers/auth_provider.dart';
class ReactionsProvider with ChangeNotifier {
final ReactionsRepository _repository;
final AuthProvider _authProvider;
ReactionsProvider(this._repository, this._authProvider) {
_authProvider.addListener(_onAuthChanged);
}
bool _isLoading = false;
bool get isLoading => _isLoading;
String? _error;
String? get error => _error;
Map<String, int> _reactionCounts = {};
Map<String, int> get reactionCounts => _reactionCounts;
String? _userReaction;
String? get userReaction => _userReaction;
String? _currentMediaId;
String? _currentMediaType;
void _onAuthChanged() {
// If user logs out, clear their specific reaction data
if (!_authProvider.isAuthenticated) {
_userReaction = null;
// We can keep the public reaction counts loaded
notifyListeners();
}
}
Future<void> loadReactionsForMedia(String mediaType, String mediaId) async {
if (_currentMediaId == mediaId && _currentMediaType == mediaType) return; // Already loaded
_currentMediaId = mediaId;
_currentMediaType = mediaType;
_isLoading = true;
_error = null;
notifyListeners();
try {
_reactionCounts = await _repository.getReactionCounts(mediaType, mediaId);
if (_authProvider.isAuthenticated) {
final userReactionResult = await _repository.getMyReaction(mediaType, mediaId);
_userReaction = userReactionResult.reactionType;
} else {
_userReaction = null;
}
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> setReaction(String mediaType, String mediaId, String reactionType) async {
if (!_authProvider.isAuthenticated) {
_error = 'User not authenticated';
notifyListeners();
return;
}
final previousReaction = _userReaction;
final previousCounts = Map<String, int>.from(_reactionCounts);
// Optimistic UI update
if (_userReaction == reactionType) {
// User is deselecting their reaction - send empty string to remove
_userReaction = null;
_reactionCounts[reactionType] = (_reactionCounts[reactionType] ?? 1) - 1;
reactionType = '';
} else {
// User is selecting a new or different reaction
if (_userReaction != null) {
_reactionCounts[_userReaction!] = (_reactionCounts[_userReaction!] ?? 1) - 1;
}
_userReaction = reactionType;
_reactionCounts[reactionType] = (_reactionCounts[reactionType] ?? 0) + 1;
}
notifyListeners();
try {
await _repository.setReaction(mediaType, mediaId, reactionType);
} catch (e) {
// Revert on error
_error = e.toString();
_userReaction = previousReaction;
_reactionCounts = previousCounts;
notifyListeners();
}
}
@override
void dispose() {
_authProvider.removeListener(_onAuthChanged);
super.dispose();
}
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:neomovies_mobile/data/models/movie.dart';
import 'package:neomovies_mobile/data/repositories/movie_repository.dart';
class SearchProvider extends ChangeNotifier {
final MovieRepository _repository;
SearchProvider(this._repository);
List<Movie> _results = [];
List<Movie> get results => _results;
bool _isLoading = false;
bool get isLoading => _isLoading;
String? _error;
String? get error => _error;
Future<void> search(String query) async {
if (query.trim().isEmpty) return;
_isLoading = true;
_error = null;
notifyListeners();
try {
_results = await _repository.searchMovies(query);
_results.sort((a, b) => b.popularity.compareTo(a.popularity));
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
void clear() {
_results = [];
_error = null;
notifyListeners();
}
}

View File

@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:neomovies_mobile/presentation/providers/auth_provider.dart';
import 'package:neomovies_mobile/presentation/screens/auth/verify_screen.dart';
import 'package:provider/provider.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Account'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Login'),
Tab(text: 'Register'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_LoginForm(),
_RegisterForm(),
],
),
);
}
}
class _LoginForm extends StatefulWidget {
@override
__LoginFormState createState() => __LoginFormState();
}
class __LoginFormState extends State<_LoginForm> {
final _formKey = GlobalKey<FormState>();
String _email = '';
String _password = '';
void _submit() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
Provider.of<AuthProvider>(context, listen: false).login(_email, _password);
}
}
@override
Widget build(BuildContext context) {
return Consumer<AuthProvider>(
builder: (context, auth, child) {
if (auth.needsVerification && auth.pendingEmail != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => VerifyScreen(email: auth.pendingEmail!),
),
);
auth.clearVerificationFlag();
});
} else if (auth.state == AuthState.authenticated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pop();
});
}
return Form(
key: _formKey,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextFormField(
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
validator: (value) => value!.isEmpty ? 'Email is required' : null,
onSaved: (value) => _email = value!,
),
TextFormField(
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
validator: (value) => value!.isEmpty ? 'Password is required' : null,
onSaved: (value) => _password = value!,
),
const SizedBox(height: 20),
if (auth.state == AuthState.loading)
const CircularProgressIndicator()
else
ElevatedButton(
onPressed: _submit,
child: const Text('Login'),
),
if (auth.state == AuthState.error && auth.error != null)
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(auth.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
),
],
),
),
);
},
);
}
}
class _RegisterForm extends StatefulWidget {
@override
__RegisterFormState createState() => __RegisterFormState();
}
class __RegisterFormState extends State<_RegisterForm> {
final _formKey = GlobalKey<FormState>();
String _name = '';
String _email = '';
String _password = '';
void _submit() async {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
try {
await Provider.of<AuthProvider>(context, listen: false)
.register(_name, _email, _password);
final auth = Provider.of<AuthProvider>(context, listen: false);
// Проверяем, что регистрация прошла успешно
if (auth.state != AuthState.error) {
// Переходим к экрану верификации
if (mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => VerifyScreen(email: _email),
),
);
}
}
} catch (e) {
// Обрабатываем ошибку, если она произошла
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Registration error: ${e.toString()}'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
}
}
@override
Widget build(BuildContext context) {
return Consumer<AuthProvider>(
builder: (context, auth, child) {
return Form(
key: _formKey,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextFormField(
decoration: const InputDecoration(labelText: 'Name'),
validator: (value) => value!.isEmpty ? 'Name is required' : null,
onSaved: (value) => _name = value!,
),
TextFormField(
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
validator: (value) => value!.isEmpty ? 'Email is required' : null,
onSaved: (value) => _email = value!,
),
TextFormField(
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
validator: (value) => value!.length < 6 ? 'Password must be at least 6 characters long' : null,
onSaved: (value) => _password = value!,
),
const SizedBox(height: 20),
if (auth.state == AuthState.loading)
const CircularProgressIndicator()
else
ElevatedButton(
onPressed: _submit,
child: const Text('Register'),
),
if (auth.state == AuthState.error && auth.error != null)
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(auth.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
),
],
),
),
);
},
);
}
}

View File

@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import 'package:neomovies_mobile/presentation/providers/auth_provider.dart';
import 'package:neomovies_mobile/presentation/screens/auth/login_screen.dart';
import 'package:provider/provider.dart';
import '../misc/licenses_screen.dart' as licenses;
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Profile'),
),
body: Consumer<AuthProvider>(
builder: (context, authProvider, child) {
switch (authProvider.state) {
case AuthState.initial:
case AuthState.loading:
return const Center(child: CircularProgressIndicator());
case AuthState.unauthenticated:
return _buildUnauthenticatedView(context);
case AuthState.authenticated:
return _buildAuthenticatedView(context, authProvider);
case AuthState.error:
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: ${authProvider.error}'),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () => authProvider.checkAuthStatus(),
child: const Text('Try again'),
)
],
),
);
}
},
),
);
}
Widget _buildUnauthenticatedView(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Please log in to continue'),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const LoginScreen()),
);
},
child: const Text('Login or Register'),
),
const SizedBox(height: 40),
TextButton(
onPressed: () => _showLicensesScreen(context),
child: const Text('Libraries licenses'),
),
],
),
);
}
Widget _buildAuthenticatedView(BuildContext context, AuthProvider authProvider) {
final user = authProvider.user!;
final initial = user.name.isNotEmpty ? user.name[0].toUpperCase() : '?';
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: CircleAvatar(
radius: 40,
child: Text(initial, style: Theme.of(context).textTheme.headlineMedium),
),
),
const SizedBox(height: 16),
Center(
child: Text(user.name, style: Theme.of(context).textTheme.headlineSmall),
),
const SizedBox(height: 8),
Center(
child: Text(user.email, style: Theme.of(context).textTheme.bodyMedium),
),
const Spacer(),
TextButton(
onPressed: () => _showLicensesScreen(context),
child: const Text('Libraries licenses'),
),
const SizedBox(height: 10),
ElevatedButton(
onPressed: () {
authProvider.logout();
},
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer,
),
child: const Text('Logout'),
),
const SizedBox(height: 10),
OutlinedButton(
onPressed: () => _showDeleteConfirmationDialog(context, authProvider),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
side: BorderSide(color: Theme.of(context).colorScheme.error),
),
child: const Text('Delete account'),
),
],
),
);
}
void _showDeleteConfirmationDialog(BuildContext context, AuthProvider authProvider) {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Delete account'),
content: const Text('Are you sure you want to delete your account? This action is irreversible.'),
actions: <Widget>[
TextButton(
child: const Text('Cancel'),
onPressed: () {
Navigator.of(dialogContext).pop();
},
),
TextButton(
style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error),
child: const Text('Delete'),
onPressed: () {
Navigator.of(dialogContext).pop();
authProvider.deleteAccount();
},
),
],
);
},
);
}
void _showLicensesScreen(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const licenses.LicensesScreen(),
),
);
}
}

View File

@@ -0,0 +1,129 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:neomovies_mobile/presentation/providers/auth_provider.dart';
import 'package:provider/provider.dart';
class VerifyScreen extends StatefulWidget {
final String email;
const VerifyScreen({super.key, required this.email});
@override
State<VerifyScreen> createState() => _VerifyScreenState();
}
class _VerifyScreenState extends State<VerifyScreen> {
final _formKey = GlobalKey<FormState>();
String _code = '';
Timer? _timer;
int _resendCooldown = 60;
bool _canResend = false;
@override
void initState() {
super.initState();
_startCooldown();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _startCooldown() {
_canResend = false;
_resendCooldown = 60;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_resendCooldown > 0) {
setState(() {
_resendCooldown--;
});
} else {
setState(() {
_canResend = true;
});
timer.cancel();
}
});
}
void _resendCode() {
if (_canResend) {
// Here you would call the provider to resend the code
// For now, just restart the timer
_startCooldown();
}
}
void _submit() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
Provider.of<AuthProvider>(context, listen: false)
.verifyEmail(widget.email, _code)
.then((_) {
final auth = Provider.of<AuthProvider>(context, listen: false);
if (auth.state != AuthState.error) {
Navigator.of(context).pop(); // Go back to LoginScreen
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Email verified. You can now login.')),
);
}
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Verify Email'),
),
body: Consumer<AuthProvider>(
builder: (context, auth, child) {
return Form(
key: _formKey,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Text('We sent a verification code to ${widget.email}. Enter it below.'),
const SizedBox(height: 20),
TextFormField(
decoration: const InputDecoration(labelText: 'Verification code'),
keyboardType: TextInputType.number,
validator: (value) => value!.isEmpty ? 'Enter code' : null,
onSaved: (value) => _code = value!,
),
const SizedBox(height: 20),
if (auth.state == AuthState.loading)
const CircularProgressIndicator()
else
ElevatedButton(
onPressed: _submit,
child: const Text('Verify'),
),
const SizedBox(height: 20),
TextButton(
onPressed: _canResend ? _resendCode : null,
child: Text(
_canResend
? 'Resend code'
: 'Resend code in $_resendCooldown seconds',
),
),
if (auth.state == AuthState.error && auth.error != null)
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(auth.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
),
],
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:neomovies_mobile/presentation/providers/auth_provider.dart';
import 'package:neomovies_mobile/presentation/providers/favorites_provider.dart';
import 'package:neomovies_mobile/presentation/screens/auth/login_screen.dart';
import 'package:neomovies_mobile/presentation/widgets/movie_grid_item.dart';
import 'package:neomovies_mobile/utils/device_utils.dart';
import 'package:provider/provider.dart';
class FavoritesScreen extends StatelessWidget {
const FavoritesScreen({super.key});
@override
Widget build(BuildContext context) {
final authProvider = Provider.of<AuthProvider>(context);
if (!authProvider.isAuthenticated) {
return _buildLoggedOutView(context);
} else {
return _buildLoggedInView(context);
}
}
Widget _buildLoggedOutView(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.favorite_border, size: 80, color: Colors.grey),
const SizedBox(height: 24),
const Text(
'Login to see your favorites',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
const Text(
'Save movies and TV shows to keep them.',
style: TextStyle(fontSize: 16, color: Colors.grey),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => const LoginScreen(),
));
},
child: const Text('Login to your account'),
),
],
),
),
);
}
Widget _buildLoggedInView(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Favorites'),
),
body: Consumer<FavoritesProvider>(
builder: (context, favoritesProvider, child) {
if (favoritesProvider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (favoritesProvider.error != null) {
return Center(child: Text('Error: ${favoritesProvider.error}'));
}
if (favoritesProvider.favorites.isEmpty) {
return _buildEmptyFavoritesView(context);
}
final gridCount = DeviceUtils.calculateGridCount(context);
return GridView.builder(
padding: const EdgeInsets.all(16.0),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: gridCount,
childAspectRatio: 0.56,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: favoritesProvider.favorites.length,
itemBuilder: (context, index) {
final favorite = favoritesProvider.favorites[index];
return MovieGridItem(favorite: favorite);
},
);
},
),
);
}
Widget _buildEmptyFavoritesView(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.movie_filter_outlined, size: 80, color: Colors.grey),
const SizedBox(height: 24),
const Text(
'Favorites are empty',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
const Text(
'Add movies by tapping on the heart.',
style: TextStyle(fontSize: 16, color: Colors.grey),
textAlign: TextAlign.center,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:neomovies_mobile/data/models/movie.dart';
import 'package:neomovies_mobile/presentation/providers/home_provider.dart';
import 'package:neomovies_mobile/presentation/providers/movie_list_provider.dart';
import 'package:neomovies_mobile/presentation/screens/movie_list_screen.dart';
import 'package:neomovies_mobile/presentation/widgets/movie_card.dart';
import 'package:provider/provider.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Home'),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () {
// TODO: Navigate to settings screen
},
),
],
),
body: Consumer<HomeProvider>(
builder: (context, provider, child) {
// Показываем загрузку только при первом запуске
if (provider.isLoading && provider.popularMovies.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
// Показываем ошибку, если она есть
if (provider.errorMessage != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(provider.errorMessage!, textAlign: TextAlign.center),
),
);
}
// Основной контент с возможностью "потянуть для обновления"
return RefreshIndicator(
onRefresh: provider.fetchAllMovies,
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8.0),
children: [
if (provider.popularMovies.isNotEmpty)
_MovieCarousel(
title: 'Popular Movies',
movies: provider.popularMovies,
category: MovieCategory.popular,
),
if (provider.upcomingMovies.isNotEmpty)
_MovieCarousel(
title: 'Latest Movies',
movies: provider.upcomingMovies,
category: MovieCategory.upcoming,
),
if (provider.topRatedMovies.isNotEmpty)
_MovieCarousel(
title: 'Top Rated Movies',
movies: provider.topRatedMovies,
category: MovieCategory.topRated,
),
],
),
);
},
),
);
}
}
// Вспомогательный виджет для карусели фильмов
class _MovieCarousel extends StatelessWidget {
final String title;
final List<Movie> movies;
final MovieCategory category;
const _MovieCarousel({
required this.title,
required this.movies,
required this.category,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 8),
SizedBox(
height: 280, // Maintained height for movie cards
child: ListView.builder(
scrollDirection: Axis.horizontal,
// Add one more item for the 'More' button
itemCount: movies.length + 1,
itemBuilder: (context, index) {
// If it's the last item, show the 'More' button
if (index == movies.length) {
return _buildMoreButton(context);
}
final movie = movies[index];
return Padding(
padding: EdgeInsets.only(
left: index == 0 ? 2.0 : 2.0,
),
child: MovieCard(movie: movie),
);
},
),
),
const SizedBox(height: 16), // Further reduced bottom padding
],
);
}
// A new widget for the 'More' button
Widget _buildMoreButton(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: 150, // Same width as MovieCard
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MovieListScreen(category: category),
),
);
},
borderRadius: BorderRadius.circular(12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.arrow_forward_ios_rounded, size: 40),
const SizedBox(height: 8),
Text(
'More',
style: Theme.of(context).textTheme.titleLarge,
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:neomovies_mobile/presentation/providers/auth_provider.dart';
import 'package:neomovies_mobile/presentation/screens/auth/profile_screen.dart';
import 'package:neomovies_mobile/presentation/screens/favorites/favorites_screen.dart';
import 'package:neomovies_mobile/presentation/screens/search/search_screen.dart';
import 'package:neomovies_mobile/presentation/screens/home/home_screen.dart';
import 'package:provider/provider.dart';
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
int _selectedIndex = 0;
@override
void initState() {
super.initState();
// Check auth status when the main screen is initialized
WidgetsBinding.instance.addPostFrameCallback((_) {
Provider.of<AuthProvider>(context, listen: false).checkAuthStatus();
});
}
// Pages for each tab
static const List<Widget> _widgetOptions = <Widget>[
HomeScreen(),
SearchScreen(),
FavoritesScreen(),
Center(child: Text('Downloads Page')),
ProfileScreen(),
];
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: _widgetOptions,
),
bottomNavigationBar: NavigationBarTheme(
data: NavigationBarThemeData(
backgroundColor: colorScheme.surfaceContainerHighest,
indicatorColor: colorScheme.surfaceContainerHighest.withOpacity(0.6),
iconTheme: MaterialStateProperty.resolveWith<IconThemeData>((states) {
if (states.contains(MaterialState.selected)) {
return IconThemeData(color: colorScheme.onSurface);
}
return IconThemeData(color: colorScheme.onSurfaceVariant);
}),
labelTextStyle: MaterialStateProperty.resolveWith<TextStyle>((states) {
if (states.contains(MaterialState.selected)) {
return TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
);
}
return TextStyle(
color: colorScheme.onSurfaceVariant,
);
}),
),
child: NavigationBar(
onDestinationSelected: _onItemTapped,
selectedIndex: _selectedIndex,
destinations: const <NavigationDestination>[
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.search),
selectedIcon: Icon(Icons.search),
label: 'Search',
),
NavigationDestination(
icon: Icon(Icons.favorite_border),
selectedIcon: Icon(Icons.favorite),
label: 'Favorites',
),
NavigationDestination(
icon: Icon(Icons.download_outlined),
selectedIcon: Icon(Icons.download),
label: 'Downloads',
),
NavigationDestination(
icon: Icon(Icons.person_2_outlined),
selectedIcon: Icon(Icons.person_2),
label: 'Profile',
),
],
),
),
);
}
}

View File

@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../providers/licenses_provider.dart';
import '../../../data/models/library_license.dart';
class LicensesScreen extends StatelessWidget {
const LicensesScreen({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => LicensesProvider(),
child: const _LicensesView(),
);
}
}
class _LicensesView extends StatelessWidget {
const _LicensesView();
@override
Widget build(BuildContext context) {
final provider = context.watch<LicensesProvider>();
return Scaffold(
appBar: AppBar(
title: const Text('Licenses'),
actions: [
ValueListenableBuilder<bool>(
valueListenable: provider.isLoading,
builder: (context, isLoading, child) {
return IconButton(
icon: isLoading ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.refresh),
onPressed: isLoading ? null : () => provider.loadLicenses(forceRefresh: true),
);
},
),
],
),
body: ValueListenableBuilder<String?>(
valueListenable: provider.error,
builder: (context, error, child) {
if (error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(error, textAlign: TextAlign.center),
),
);
}
return ValueListenableBuilder<List<LibraryLicense>>(
valueListenable: provider.licenses,
builder: (context, licenses, child) {
if (licenses.isEmpty && provider.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (licenses.isEmpty) {
return const Center(child: Text('No licenses found.'));
}
return ListView.builder(
itemCount: licenses.length,
itemBuilder: (context, index) {
final license = licenses[index];
return ListTile(
title: Text('${license.name} (${license.version})'),
subtitle: Text('License: ${license.license}'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (license.url.isNotEmpty)
IconButton(
icon: const Icon(Icons.code), // GitHub icon or similar
tooltip: 'Source Code',
onPressed: () => _launchURL(license.url),
),
IconButton(
icon: const Icon(Icons.description_outlined),
tooltip: 'View License',
onPressed: () => _showLicenseDialog(context, provider, license),
),
],
),
);
},
);
},
);
},
),
);
}
Future<void> _launchURL(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
// Optionally, show a snackbar or dialog on failure
}
}
void _showLicenseDialog(BuildContext context, LicensesProvider provider, LibraryLicense license) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(license.name),
content: SizedBox(
width: double.maxFinite,
child: FutureBuilder<String>(
future: provider.fetchLicenseText(license),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Text('Failed to load license: ${snapshot.error}');
}
return SingleChildScrollView(
child: Text(snapshot.data ?? 'No license text available.'),
);
},
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Close')),
],
),
);
}
}

View File

@@ -0,0 +1,356 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:neomovies_mobile/presentation/providers/auth_provider.dart';
import 'package:neomovies_mobile/presentation/providers/favorites_provider.dart';
import 'package:neomovies_mobile/presentation/providers/reactions_provider.dart';
import 'package:neomovies_mobile/presentation/providers/movie_detail_provider.dart';
import 'package:neomovies_mobile/presentation/screens/player/video_player_screen.dart';
import 'package:provider/provider.dart';
class MovieDetailScreen extends StatefulWidget {
final String movieId;
final String mediaType;
const MovieDetailScreen({super.key, required this.movieId, this.mediaType = 'movie'});
@override
State<MovieDetailScreen> createState() => _MovieDetailScreenState();
}
class _MovieDetailScreenState extends State<MovieDetailScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
// Load movie details and reactions
Provider.of<MovieDetailProvider>(context, listen: false).loadMedia(int.parse(widget.movieId), widget.mediaType);
Provider.of<ReactionsProvider>(context, listen: false).loadReactionsForMedia(widget.mediaType, widget.movieId);
});
}
void _openPlayer(BuildContext context, String? imdbId, String title) {
if (imdbId == null || imdbId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('IMDB ID not found. Cannot open player.'),
duration: Duration(seconds: 3),
),
);
return;
}
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => VideoPlayerScreen(
mediaId: imdbId,
mediaType: widget.mediaType,
title: title,
),
),
);
}
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
),
body: Consumer<MovieDetailProvider>(
builder: (context, provider, child) {
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (provider.error != null) {
return Center(child: Text('Error: ${provider.error}'));
}
if (provider.movie == null) {
return const Center(child: Text('Movie not found'));
}
final movie = provider.movie!;
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Poster
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: AspectRatio(
aspectRatio: 2 / 3,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
imageUrl: movie.fullPosterUrl,
fit: BoxFit.cover,
placeholder: (context, url) => const Center(child: CircularProgressIndicator()),
errorWidget: (context, url, error) => const Icon(Icons.error),
),
),
),
),
),
const SizedBox(height: 24),
// Title
Text(
movie.title,
style: textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
// Tagline
if (movie.tagline != null && movie.tagline!.isNotEmpty)
Text(
movie.tagline!,
style: textTheme.titleMedium?.copyWith(color: textTheme.bodySmall?.color),
),
const SizedBox(height: 16),
// Meta Info
Wrap(
spacing: 8.0,
runSpacing: 4.0,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text('Рейтинг: ${movie.voteAverage?.toStringAsFixed(1) ?? 'N/A'}'),
const Text('|'),
if (movie.mediaType == 'tv')
Text('${movie.seasonsCount ?? '-'} сез., ${movie.episodesCount ?? '-'} сер.')
else if (movie.runtime != null)
Text('${movie.runtime} мин.'),
const Text('|'),
if (movie.releaseDate != null)
Text(DateFormat('d MMMM yyyy', 'ru').format(movie.releaseDate!)),
],
),
const SizedBox(height: 16),
// Genres
if (movie.genres != null && movie.genres!.isNotEmpty)
Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: movie.genres!
.map((genre) => Chip(
label: Text(genre),
backgroundColor: colorScheme.secondaryContainer,
labelStyle: textTheme.bodySmall?.copyWith(color: colorScheme.onSecondaryContainer),
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
))
.toList(),
),
const SizedBox(height: 24),
// Reactions Section
_buildReactionsSection(context),
const SizedBox(height: 24),
// Overview
Text(
'Описание',
style: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
movie.overview ?? 'Описание недоступно.',
style: textTheme.bodyMedium,
),
const SizedBox(height: 24),
// Action Buttons
Row(
children: [
Expanded(
child: Consumer<MovieDetailProvider>(
builder: (context, provider, child) {
final imdbId = provider.imdbId;
final isImdbLoading = provider.isImdbLoading;
return ElevatedButton.icon(
onPressed: (isImdbLoading || imdbId == null)
? null // Делаем кнопку неактивной во время загрузки или если нет ID
: () {
_openPlayer(context, imdbId, provider.movie!.title);
},
icon: isImdbLoading
? Container(
width: 24,
height: 24,
padding: const EdgeInsets.all(2.0),
child: const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
)
: const Icon(Icons.play_arrow),
label: const Text('Смотреть'),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
).copyWith(
// Устанавливаем цвет для неактивного состояния
backgroundColor: MaterialStateProperty.resolveWith<Color?>(
(Set<MaterialState> states) {
if (states.contains(MaterialState.disabled)) {
return Colors.grey;
}
return Theme.of(context).colorScheme.primary;
},
),
),
);
},
),
),
const SizedBox(width: 16),
Consumer<FavoritesProvider>(
builder: (context, favoritesProvider, child) {
final isFavorite = favoritesProvider.isFavorite(widget.movieId);
return IconButton(
onPressed: () {
final authProvider = context.read<AuthProvider>();
if (!authProvider.isAuthenticated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Войдите в аккаунт, чтобы добавлять в избранное.'),
duration: Duration(seconds: 2),
),
);
return;
}
if (isFavorite) {
favoritesProvider.removeFavorite(widget.movieId);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Удалено из избранного'),
duration: Duration(seconds: 2),
),
);
} else {
favoritesProvider.addFavorite(movie);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Добавлено в избранное'),
duration: Duration(seconds: 2),
),
);
}
},
icon: Icon(isFavorite ? Icons.favorite : Icons.favorite_border),
iconSize: 28,
style: IconButton.styleFrom(
backgroundColor: isFavorite ? Colors.red.withOpacity(0.1) : colorScheme.secondaryContainer,
foregroundColor: isFavorite ? Colors.red : colorScheme.onSecondaryContainer,
),
);
},
),
],
),
],
),
);
},
),
);
}
Widget _buildReactionsSection(BuildContext context) {
final authProvider = context.watch<AuthProvider>();
// Define the reactions with their icons and backend types
// Map of UI reaction types to backend types and icons
final List<Map<String, dynamic>> reactions = [
{'uiType': 'like', 'backendType': 'fire', 'icon': Icons.local_fire_department},
{'uiType': 'nice', 'backendType': 'nice', 'icon': Icons.thumb_up_alt},
{'uiType': 'think', 'backendType': 'think', 'icon': Icons.psychology},
{'uiType': 'bore', 'backendType': 'bore', 'icon': Icons.sentiment_dissatisfied},
{'uiType': 'shit', 'backendType': 'shit', 'icon': Icons.thumb_down_alt},
];
return Consumer<ReactionsProvider>(
builder: (context, provider, child) {
// Debug: Print current reaction data
// print('REACTIONS DEBUG:');
// print('- User reaction: ${provider.userReaction}');
// print('- Reaction counts: ${provider.reactionCounts}');
if (provider.isLoading && provider.reactionCounts.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (provider.error != null) {
return Center(child: Text('Error loading reactions: ${provider.error}'));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Реакции',
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: reactions.map((reaction) {
final uiType = reaction['uiType'] as String;
final backendType = reaction['backendType'] as String;
final icon = reaction['icon'] as IconData;
final count = provider.reactionCounts[backendType] ?? 0;
final isSelected = provider.userReaction == backendType;
return Column(
children: [
IconButton(
icon: Icon(icon),
iconSize: 28,
color: isSelected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
onPressed: () {
if (!authProvider.isAuthenticated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Login to your account to leave a reaction.'),
duration: Duration(seconds: 2),
),
);
return;
}
provider.setReaction(widget.mediaType, widget.movieId, backendType);
},
),
const SizedBox(height: 4),
Text(
count.toString(),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: isSelected ? Theme.of(context).colorScheme.primary : null,
fontWeight: isSelected ? FontWeight.bold : null,
),
),
],
);
}).toList(),
),
],
);
},
);
}
}

View File

@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:neomovies_mobile/data/repositories/movie_repository.dart';
import 'package:neomovies_mobile/presentation/providers/movie_list_provider.dart';
import 'package:neomovies_mobile/presentation/widgets/movie_card.dart';
import 'package:provider/provider.dart';
import '../../utils/device_utils.dart';
class MovieListScreen extends StatelessWidget {
final MovieCategory category;
const MovieListScreen({super.key, required this.category});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => MovieListProvider(
category: category,
movieRepository: context.read<MovieRepository>(),
)..fetchInitialMovies(),
child: const _MovieListScreenContent(),
);
}
}
class _MovieListScreenContent extends StatefulWidget {
const _MovieListScreenContent();
@override
State<_MovieListScreenContent> createState() => _MovieListScreenContentState();
}
class _MovieListScreenContentState extends State<_MovieListScreenContent> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
context.read<MovieListProvider>().fetchNextPage();
}
}
@override
Widget build(BuildContext context) {
final provider = context.watch<MovieListProvider>();
return Scaffold(
appBar: AppBar(
title: Text(provider.getTitle()),
),
body: _buildBody(provider),
);
}
Widget _buildBody(MovieListProvider provider) {
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (provider.errorMessage != null && provider.movies.isEmpty) {
return Center(child: Text('Error: ${provider.errorMessage}'));
}
if (provider.movies.isEmpty) {
return const Center(child: Text('No movies found.'));
}
return GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(12.0),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: DeviceUtils.calculateGridCount(context),
childAspectRatio: 0.6,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: provider.movies.length + (provider.isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= provider.movies.length) {
return const Center(child: CircularProgressIndicator());
}
final movie = provider.movies[index];
return MovieCard(movie: movie);
},
);
}
}

View File

@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:neomovies_mobile/utils/device_utils.dart';
import 'package:neomovies_mobile/presentation/widgets/player/web_player_widget.dart';
import 'package:neomovies_mobile/data/models/player/video_source.dart';
class VideoPlayerScreen extends StatefulWidget {
final String mediaId; // Теперь это IMDB ID
final String mediaType; // 'movie' or 'tv'
final String? title;
final String? subtitle;
final String? posterUrl;
const VideoPlayerScreen({
Key? key,
required this.mediaId,
required this.mediaType,
this.title,
this.subtitle,
this.posterUrl,
}) : super(key: key);
@override
State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
}
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
VideoSource _selectedSource = VideoSource.defaultSources.first;
@override
void initState() {
super.initState();
_setupPlayerEnvironment();
}
void _setupPlayerEnvironment() {
// Keep screen awake during video playback
WakelockPlus.enable();
// Set landscape orientation
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
// Hide system UI for immersive experience
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
}
@override
void dispose() {
_restoreSystemSettings();
super.dispose();
}
void _restoreSystemSettings() {
// Restore system UI and allow screen to sleep
WakelockPlus.disable();
// Restore orientation: phones back to portrait, tablets/TV keep free rotation
if (DeviceUtils.isLargeScreen(context)) {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
} else {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
}
// Restore system UI
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
_restoreSystemSettings();
return true;
},
child: _VideoPlayerScreenContent(
title: widget.title,
mediaId: widget.mediaId,
selectedSource: _selectedSource,
onSourceChanged: (source) {
if (mounted) {
setState(() {
_selectedSource = source;
});
}
},
),
);
}
}
class _VideoPlayerScreenContent extends StatelessWidget {
final String mediaId; // IMDB ID
final String? title;
final VideoSource selectedSource;
final ValueChanged<VideoSource> onSourceChanged;
const _VideoPlayerScreenContent({
Key? key,
required this.mediaId,
this.title,
required this.selectedSource,
required this.onSourceChanged,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Column(
children: [
// Source selector header
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: Colors.black87,
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
const SizedBox(width: 8),
const Text(
'Источник: ',
style: TextStyle(color: Colors.white, fontSize: 16),
),
_buildSourceSelector(),
const Spacer(),
if (title != null)
Expanded(
flex: 2,
child: Text(
title!,
style: const TextStyle(color: Colors.white, fontSize: 14),
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
),
),
],
),
),
// Video player
Expanded(
child: WebPlayerWidget(
key: ValueKey(selectedSource.id),
mediaId: mediaId,
source: selectedSource,
),
),
],
),
),
);
}
Widget _buildSourceSelector() {
return DropdownButton<VideoSource>(
value: selectedSource,
dropdownColor: Colors.black87,
style: const TextStyle(color: Colors.white),
underline: Container(),
items: VideoSource.defaultSources
.where((source) => source.isActive)
.map((source) => DropdownMenuItem<VideoSource>(
value: source,
child: Text(source.name),
))
.toList(),
onChanged: (VideoSource? newSource) {
if (newSource != null) {
onSourceChanged(newSource);
}
},
);
}
}

View File

@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:neomovies_mobile/presentation/providers/search_provider.dart';
import 'package:neomovies_mobile/presentation/widgets/movie_card.dart';
import 'package:neomovies_mobile/data/repositories/movie_repository.dart';
class SearchScreen extends StatelessWidget {
const SearchScreen({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => SearchProvider(context.read<MovieRepository>()),
child: const _SearchContent(),
);
}
}
class _SearchContent extends StatefulWidget {
const _SearchContent();
@override
State<_SearchContent> createState() => _SearchContentState();
}
class _SearchContentState extends State<_SearchContent> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onSubmitted(String query) {
context.read<SearchProvider>().search(query);
}
@override
Widget build(BuildContext context) {
final provider = context.watch<SearchProvider>();
return Scaffold(
appBar: AppBar(
title: TextField(
controller: _controller,
textInputAction: TextInputAction.search,
onSubmitted: _onSubmitted,
decoration: const InputDecoration(
hintText: 'Search movies or TV shows',
border: InputBorder.none,
),
),
actions: [
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear();
provider.clear();
},
),
],
),
body: () {
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (provider.error != null) {
return Center(child: Text('Error: ${provider.error}'));
}
if (provider.results.isEmpty) {
return const Center(child: Text('No results'));
}
return GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.6,
),
itemCount: provider.results.length,
itemBuilder: (context, index) {
final movie = provider.results[index];
return MovieCard(movie: movie);
},
);
}(),
);
}
}

View File

@@ -0,0 +1,60 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:neomovies_mobile/data/models/movie.dart';
import 'package:neomovies_mobile/presentation/providers/movie_detail_provider.dart';
import 'package:neomovies_mobile/presentation/screens/movie_detail/movie_detail_screen.dart';
import 'package:provider/provider.dart';
class MovieCard extends StatelessWidget {
final Movie movie;
const MovieCard({super.key, required this.movie});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 16.0),
child: GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MovieDetailScreen(movieId: movie.id, mediaType: movie.mediaType),
),
);
},
child: SizedBox(
width: 150,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: CachedNetworkImage(
imageUrl: movie.fullPosterUrl,
width: 150,
height: 225,
fit: BoxFit.cover,
placeholder: (context, url) => const Center(child: CircularProgressIndicator()),
errorWidget: (context, url, error) => const Icon(Icons.error),
),
),
const SizedBox(height: 8),
Expanded(
child: Text(
movie.title,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,54 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:neomovies_mobile/data/models/favorite.dart';
import 'package:neomovies_mobile/presentation/screens/movie_detail/movie_detail_screen.dart';
class MovieGridItem extends StatelessWidget {
final Favorite favorite;
const MovieGridItem({super.key, required this.favorite});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) =>
MovieDetailScreen(movieId: favorite.mediaId, mediaType: favorite.mediaType),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 2 / 3,
child: Card(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: CachedNetworkImage(
imageUrl: favorite.fullPosterUrl,
fit: BoxFit.cover,
placeholder: (context, url) => const Center(child: CircularProgressIndicator()),
errorWidget: (context, url, error) => const Icon(Icons.error),
),
),
),
const SizedBox(height: 8),
SizedBox(
height: 32,
child: Text(
favorite.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:neomovies_mobile/data/models/player/video_source.dart';
import 'package:webview_flutter/webview_flutter.dart';
class WebPlayerWidget extends StatefulWidget {
final VideoSource source;
final String? mediaId;
const WebPlayerWidget({
super.key,
required this.source,
required this.mediaId,
});
@override
State<WebPlayerWidget> createState() => _WebPlayerWidgetState();
}
class _WebPlayerWidgetState extends State<WebPlayerWidget> {
late final WebViewController _controller;
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_initializeWebView();
}
void _initializeWebView() {
if (widget.mediaId == null || widget.mediaId!.isEmpty) {
setState(() {
_error = 'Ошибка: IMDB ID не предоставлен.';
_isLoading = false;
});
return;
}
final playerUrl = '${dotenv.env['API_URL']}/players/${widget.source.id}?imdb_id=${widget.mediaId}';
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.black)
..setUserAgent(widget.source.id == 'lumex'
? 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36'
: 'Mozilla/5.0 (Linux; Android 10; SM-G975F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36')
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (NavigationRequest request) {
return NavigationDecision.navigate;
},
onPageStarted: (String url) {
if (mounted) setState(() => _isLoading = true);
},
onPageFinished: (String url) {
if (mounted) {
setState(() {
_isLoading = false;
// Сбрасываем ошибку, если страница загрузилась
_error = null;
});
}
},
onWebResourceError: (WebResourceError error) {
// Показываем ошибку только если это главный фрейм (основная страница),
// иначе игнорируем ошибки под-ресурсов (картинок, шрифтов и т.-д.).
if ((error.isForMainFrame ?? false) && mounted) {
setState(() {
_error = 'Ошибка загрузки: ${error.description}';
_isLoading = false;
});
}
},
),
)
..loadRequest(Uri.parse(playerUrl));
}
@override
void didUpdateWidget(WebPlayerWidget oldWidget) {
super.didUpdateWidget(oldWidget);
// Reload player if source or media changed
if (oldWidget.source != widget.source || oldWidget.mediaId != widget.mediaId) {
_initializeWebView();
}
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black,
child: Stack(
children: [
// WebView
WebViewWidget(controller: _controller),
// Индикатор загрузки поверх WebView
if (_isLoading)
Container(
color: Colors.black54,
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: Colors.white),
SizedBox(height: 16),
Text(
'Загрузка плеера...',
style: TextStyle(color: Colors.white),
),
],
),
),
),
// Показываем ошибку
if (_error != null)
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
color: Colors.red,
size: 48,
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
_error!,
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _initializeWebView,
child: const Text('Повторить'),
),
const SizedBox(height: 8),
// Debug info
if (widget.mediaId != null && widget.mediaId!.isNotEmpty)
Container(
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey.shade800,
borderRadius: BorderRadius.circular(4),
),
child: Column(
children: [
Text(
'Debug Info:',
style: TextStyle(
color: Colors.grey.shade300,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'IMDB ID: ${widget.mediaId}',
style: TextStyle(
color: Colors.grey.shade300,
fontSize: 10,
fontFamily: 'monospace',
),
),
Text(
'Source: ${widget.source.name}',
style: TextStyle(
color: Colors.grey.shade300,
fontSize: 10,
fontFamily: 'monospace',
),
),
Text(
'Player URL: ${dotenv.env['API_URL']}/players/${widget.source.id}?imdb_id=${widget.mediaId}',
style: TextStyle(
color: Colors.grey.shade300,
fontSize: 10,
fontFamily: 'monospace',
),
),
],
),
),
],
),
),
],
),
);
}
@override
void dispose() {
super.dispose();
}
}