mirror of
https://gitlab.com/foxixus/neomovies_mobile.git
synced 2025-10-27 19:58:50 +05:00
Initial commit
This commit is contained in:
332
lib/data/api/api_client.dart
Normal file
332
lib/data/api/api_client.dart
Normal file
@@ -0,0 +1,332 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:neomovies_mobile/data/models/auth_response.dart';
|
||||
import 'package:neomovies_mobile/data/models/favorite.dart';
|
||||
import 'package:neomovies_mobile/data/models/movie.dart';
|
||||
import 'package:neomovies_mobile/data/models/reaction.dart';
|
||||
import 'package:neomovies_mobile/data/models/user.dart';
|
||||
|
||||
class ApiClient {
|
||||
final http.Client _client;
|
||||
final String _baseUrl = dotenv.env['API_URL']!;
|
||||
|
||||
ApiClient(this._client);
|
||||
|
||||
Future<List<Movie>> getPopularMovies({int page = 1}) async {
|
||||
return _fetchMovies('/movies/popular', page: page);
|
||||
}
|
||||
|
||||
Future<List<Movie>> getTopRatedMovies({int page = 1}) async {
|
||||
return _fetchMovies('/movies/top-rated', page: page);
|
||||
}
|
||||
|
||||
Future<List<Movie>> getUpcomingMovies({int page = 1}) async {
|
||||
return _fetchMovies('/movies/upcoming', page: page);
|
||||
}
|
||||
|
||||
Future<Movie> getMovieById(String id) async {
|
||||
return _fetchMovieDetail('/movies/$id');
|
||||
}
|
||||
|
||||
Future<Movie> getTvById(String id) async {
|
||||
return _fetchMovieDetail('/tv/$id');
|
||||
}
|
||||
|
||||
// Получение IMDB ID для фильмов
|
||||
Future<String?> getMovieImdbId(int movieId) async {
|
||||
try {
|
||||
final uri = Uri.parse('$_baseUrl/movies/$movieId/external-ids');
|
||||
final response = await _client.get(uri).timeout(const Duration(seconds: 30));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
return data['imdb_id'] as String?;
|
||||
} else {
|
||||
print('Failed to get movie IMDB ID: ${response.statusCode}');
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error getting movie IMDB ID: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение IMDB ID для сериалов
|
||||
Future<String?> getTvImdbId(int showId) async {
|
||||
try {
|
||||
final uri = Uri.parse('$_baseUrl/tv/$showId/external-ids');
|
||||
final response = await _client.get(uri).timeout(const Duration(seconds: 30));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
return data['imdb_id'] as String?;
|
||||
} else {
|
||||
print('Failed to get TV IMDB ID: ${response.statusCode}');
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error getting TV IMDB ID: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Универсальный метод получения IMDB ID
|
||||
Future<String?> getImdbId(int mediaId, String mediaType) async {
|
||||
if (mediaType == 'tv') {
|
||||
return getTvImdbId(mediaId);
|
||||
} else {
|
||||
return getMovieImdbId(mediaId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Movie>> searchMovies(String query, {int page = 1}) async {
|
||||
final moviesUri = Uri.parse('$_baseUrl/movies/search?query=${Uri.encodeQueryComponent(query)}&page=$page');
|
||||
final tvUri = Uri.parse('$_baseUrl/tv/search?query=${Uri.encodeQueryComponent(query)}&page=$page');
|
||||
|
||||
final responses = await Future.wait([
|
||||
_client.get(moviesUri),
|
||||
_client.get(tvUri),
|
||||
]);
|
||||
|
||||
List<Movie> combined = [];
|
||||
|
||||
for (final response in responses) {
|
||||
if (response.statusCode == 200) {
|
||||
final decoded = json.decode(response.body);
|
||||
List<dynamic> listData;
|
||||
if (decoded is List) {
|
||||
listData = decoded;
|
||||
} else if (decoded is Map && decoded['results'] is List) {
|
||||
listData = decoded['results'];
|
||||
} else {
|
||||
listData = [];
|
||||
}
|
||||
combined.addAll(listData.map((json) => Movie.fromJson(json)));
|
||||
} else {
|
||||
// ignore non-200 but log maybe
|
||||
}
|
||||
}
|
||||
|
||||
if (combined.isEmpty) {
|
||||
throw Exception('Failed to search movies/tv');
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
Future<Movie> _fetchMovieDetail(String path) async {
|
||||
final uri = Uri.parse('$_baseUrl$path');
|
||||
final response = await _client.get(uri);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
return Movie.fromJson(data);
|
||||
} else {
|
||||
throw Exception('Failed to load media details: ${response.statusCode}');
|
||||
}
|
||||
}
|
||||
|
||||
// Favorites
|
||||
Future<List<Favorite>> getFavorites() async {
|
||||
final response = await _client.get(Uri.parse('$_baseUrl/favorites'));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = json.decode(response.body);
|
||||
return data.map((json) => Favorite.fromJson(json)).toList();
|
||||
} else {
|
||||
throw Exception('Failed to fetch favorites');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addFavorite(String mediaId, String mediaType, String title, String posterPath) async {
|
||||
final response = await _client.post(
|
||||
Uri.parse('$_baseUrl/favorites/$mediaId?mediaType=$mediaType'),
|
||||
body: json.encode({
|
||||
'title': title,
|
||||
'posterPath': posterPath,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 201 && response.statusCode != 200) {
|
||||
throw Exception('Failed to add favorite');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeFavorite(String mediaId) async {
|
||||
final response = await _client.delete(
|
||||
Uri.parse('$_baseUrl/favorites/$mediaId'),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to remove favorite');
|
||||
}
|
||||
}
|
||||
|
||||
// Reactions
|
||||
Future<Map<String, int>> getReactionCounts(String mediaType, String mediaId) async {
|
||||
final response = await _client.get(
|
||||
Uri.parse('$_baseUrl/reactions/$mediaType/$mediaId/counts'),
|
||||
);
|
||||
|
||||
print('REACTION COUNTS RESPONSE (${response.statusCode}): ${response.body}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final decoded = json.decode(response.body);
|
||||
print('PARSED: $decoded');
|
||||
|
||||
if (decoded is Map) {
|
||||
final mapSrc = decoded.containsKey('data') && decoded['data'] is Map
|
||||
? decoded['data'] as Map<String, dynamic>
|
||||
: decoded;
|
||||
|
||||
print('MAPPING: $mapSrc');
|
||||
return mapSrc.map((k, v) {
|
||||
int count;
|
||||
if (v is num) {
|
||||
count = v.toInt();
|
||||
} else if (v is String) {
|
||||
count = int.tryParse(v) ?? 0;
|
||||
} else {
|
||||
count = 0;
|
||||
}
|
||||
return MapEntry(k, count);
|
||||
});
|
||||
}
|
||||
if (decoded is List) {
|
||||
// list of {type,count}
|
||||
Map<String, int> res = {};
|
||||
for (var item in decoded) {
|
||||
if (item is Map && item['type'] != null) {
|
||||
res[item['type'].toString()] = (item['count'] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
return {};
|
||||
} else {
|
||||
throw Exception('Failed to fetch reactions counts');
|
||||
}
|
||||
}
|
||||
|
||||
Future<UserReaction> getMyReaction(String mediaType, String mediaId) async {
|
||||
final response = await _client.get(
|
||||
Uri.parse('$_baseUrl/reactions/$mediaType/$mediaId/my-reaction'),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final decoded = json.decode(response.body);
|
||||
if (decoded == null || (decoded is String && decoded.isEmpty)) {
|
||||
return UserReaction(reactionType: null);
|
||||
}
|
||||
return UserReaction.fromJson(decoded as Map<String, dynamic>);
|
||||
} else if (response.statusCode == 404) {
|
||||
return UserReaction(reactionType: 'none'); // No reaction found
|
||||
} else {
|
||||
throw Exception('Failed to fetch user reaction');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setReaction(String mediaType, String mediaId, String reactionType) async {
|
||||
final response = await _client.post(
|
||||
Uri.parse('$_baseUrl/reactions'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({'mediaId': '${mediaType}_${mediaId}', 'type': reactionType}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 201 && response.statusCode != 200 && response.statusCode != 204) {
|
||||
throw Exception('Failed to set reaction: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auth Methods ---
|
||||
|
||||
Future<void> register(String name, String email, String password) async {
|
||||
final uri = Uri.parse('$_baseUrl/auth/register');
|
||||
final response = await _client.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({'name': name, 'email': email, 'password': password}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 201 || response.statusCode == 200) {
|
||||
final decoded = json.decode(response.body) as Map<String, dynamic>;
|
||||
if (decoded['success'] == true || decoded.containsKey('token')) {
|
||||
// registration succeeded; nothing further to return
|
||||
return;
|
||||
} else {
|
||||
throw Exception('Failed to register: ${decoded['message'] ?? 'Unknown error'}');
|
||||
}
|
||||
} else {
|
||||
throw Exception('Failed to register: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AuthResponse> login(String email, String password) async {
|
||||
final uri = Uri.parse('$_baseUrl/auth/login');
|
||||
final response = await _client.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({'email': email, 'password': password}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return AuthResponse.fromJson(json.decode(response.body));
|
||||
} else {
|
||||
throw Exception('Failed to login: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> verify(String email, String code) async {
|
||||
final uri = Uri.parse('$_baseUrl/auth/verify');
|
||||
final response = await _client.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({'email': email, 'code': code}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to verify code: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> resendCode(String email) async {
|
||||
final uri = Uri.parse('$_baseUrl/auth/resend-code');
|
||||
final response = await _client.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({'email': email}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to resend code: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteAccount() async {
|
||||
final uri = Uri.parse('$_baseUrl/auth/profile');
|
||||
final response = await _client.delete(uri);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to delete account: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Movie Methods ---
|
||||
|
||||
Future<List<Movie>> _fetchMovies(String endpoint, {int page = 1}) async {
|
||||
final uri = Uri.parse('$_baseUrl$endpoint').replace(queryParameters: {
|
||||
'page': page.toString(),
|
||||
});
|
||||
final response = await _client.get(uri);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = json.decode(response.body)['results'];
|
||||
if (data == null) {
|
||||
return [];
|
||||
}
|
||||
return data.map((json) => Movie.fromJson(json)).toList();
|
||||
} else {
|
||||
throw Exception('Failed to load movies from $endpoint');
|
||||
}
|
||||
}
|
||||
}
|
||||
19
lib/data/api/authenticated_http_client.dart
Normal file
19
lib/data/api/authenticated_http_client.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:neomovies_mobile/data/services/secure_storage_service.dart';
|
||||
|
||||
class AuthenticatedHttpClient extends http.BaseClient {
|
||||
final http.Client _inner;
|
||||
final SecureStorageService _storageService;
|
||||
|
||||
AuthenticatedHttpClient(this._storageService, this._inner);
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
final token = await _storageService.getToken();
|
||||
if (token != null) {
|
||||
request.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
request.headers['Content-Type'] = 'application/json';
|
||||
return _inner.send(request);
|
||||
}
|
||||
}
|
||||
9
lib/data/exceptions/auth_exceptions.dart
Normal file
9
lib/data/exceptions/auth_exceptions.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
class UnverifiedAccountException implements Exception {
|
||||
final String email;
|
||||
final String? message;
|
||||
|
||||
UnverifiedAccountException(this.email, {this.message});
|
||||
|
||||
@override
|
||||
String toString() => message ?? 'Account not verified';
|
||||
}
|
||||
17
lib/data/models/auth_response.dart
Normal file
17
lib/data/models/auth_response.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'package:neomovies_mobile/data/models/user.dart';
|
||||
|
||||
class AuthResponse {
|
||||
final String token;
|
||||
final User user;
|
||||
final bool verified;
|
||||
|
||||
AuthResponse({required this.token, required this.user, required this.verified});
|
||||
|
||||
factory AuthResponse.fromJson(Map<String, dynamic> json) {
|
||||
return AuthResponse(
|
||||
token: json['token'] as String,
|
||||
user: User.fromJson(json['user'] as Map<String, dynamic>),
|
||||
verified: (json['verified'] as bool?) ?? (json['user']?['verified'] as bool? ?? true),
|
||||
);
|
||||
}
|
||||
}
|
||||
36
lib/data/models/favorite.dart
Normal file
36
lib/data/models/favorite.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
|
||||
class Favorite {
|
||||
final int id;
|
||||
final String mediaId;
|
||||
final String mediaType;
|
||||
final String title;
|
||||
final String posterPath;
|
||||
|
||||
Favorite({
|
||||
required this.id,
|
||||
required this.mediaId,
|
||||
required this.mediaType,
|
||||
required this.title,
|
||||
required this.posterPath,
|
||||
});
|
||||
|
||||
factory Favorite.fromJson(Map<String, dynamic> json) {
|
||||
return Favorite(
|
||||
id: json['id'] as int? ?? 0,
|
||||
mediaId: json['mediaId'] as String? ?? '',
|
||||
mediaType: json['mediaType'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
posterPath: json['posterPath'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
String get fullPosterUrl {
|
||||
final baseUrl = dotenv.env['API_URL']!;
|
||||
if (posterPath.isEmpty) {
|
||||
return '$baseUrl/images/w500/placeholder.jpg';
|
||||
}
|
||||
final cleanPath = posterPath.startsWith('/') ? posterPath.substring(1) : posterPath;
|
||||
return '$baseUrl/images/w500/$cleanPath';
|
||||
}
|
||||
}
|
||||
57
lib/data/models/library_license.dart
Normal file
57
lib/data/models/library_license.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
class LibraryLicense {
|
||||
final String name;
|
||||
final String version;
|
||||
final String license;
|
||||
final String url;
|
||||
final String description;
|
||||
final String? licenseText;
|
||||
|
||||
const LibraryLicense({
|
||||
required this.name,
|
||||
required this.version,
|
||||
required this.license,
|
||||
required this.url,
|
||||
required this.description,
|
||||
this.licenseText,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'name': name,
|
||||
'version': version,
|
||||
'license': license,
|
||||
'url': url,
|
||||
'description': description,
|
||||
'licenseText': licenseText,
|
||||
};
|
||||
}
|
||||
|
||||
LibraryLicense copyWith({
|
||||
String? name,
|
||||
String? version,
|
||||
String? license,
|
||||
String? url,
|
||||
String? description,
|
||||
String? licenseText,
|
||||
}) {
|
||||
return LibraryLicense(
|
||||
name: name ?? this.name,
|
||||
version: version ?? this.version,
|
||||
license: license ?? this.license,
|
||||
url: url ?? this.url,
|
||||
description: description ?? this.description,
|
||||
licenseText: licenseText ?? this.licenseText,
|
||||
);
|
||||
}
|
||||
|
||||
factory LibraryLicense.fromMap(Map<String, dynamic> map) {
|
||||
return LibraryLicense(
|
||||
name: map['name'] as String? ?? '',
|
||||
version: map['version'] as String? ?? '',
|
||||
license: map['license'] as String? ?? '',
|
||||
url: map['url'] as String? ?? '',
|
||||
description: map['description'] as String? ?? '',
|
||||
licenseText: map['licenseText'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
100
lib/data/models/movie.dart
Normal file
100
lib/data/models/movie.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'movie.g.dart';
|
||||
|
||||
@HiveType(typeId: 0)
|
||||
class Movie extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String title;
|
||||
|
||||
@HiveField(2)
|
||||
final String? posterPath;
|
||||
|
||||
@HiveField(3)
|
||||
final String? overview;
|
||||
|
||||
@HiveField(4)
|
||||
final DateTime? releaseDate;
|
||||
|
||||
@HiveField(5)
|
||||
final List<String>? genres;
|
||||
|
||||
@HiveField(6)
|
||||
final double? voteAverage;
|
||||
|
||||
// Поле популярности из API (TMDB-style)
|
||||
@HiveField(9)
|
||||
final double popularity;
|
||||
|
||||
@HiveField(7)
|
||||
final int? runtime;
|
||||
|
||||
// TV specific
|
||||
@HiveField(10)
|
||||
final int? seasonsCount;
|
||||
@HiveField(11)
|
||||
final int? episodesCount;
|
||||
|
||||
@HiveField(8)
|
||||
final String? tagline;
|
||||
|
||||
// not stored in Hive, runtime-only field
|
||||
final String mediaType;
|
||||
|
||||
Movie({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.posterPath,
|
||||
this.overview,
|
||||
this.releaseDate,
|
||||
this.genres,
|
||||
this.voteAverage,
|
||||
this.popularity = 0.0,
|
||||
this.runtime,
|
||||
this.seasonsCount,
|
||||
this.episodesCount,
|
||||
this.tagline,
|
||||
this.mediaType = 'movie',
|
||||
});
|
||||
|
||||
factory Movie.fromJson(Map<String, dynamic> json) {
|
||||
return Movie(
|
||||
id: (json['id'] as num).toString(), // Ensure id is a string
|
||||
title: (json['title'] ?? json['name'] ?? '') as String,
|
||||
posterPath: json['poster_path'] as String?,
|
||||
overview: json['overview'] as String?,
|
||||
releaseDate: json['release_date'] != null && json['release_date'].isNotEmpty
|
||||
? DateTime.tryParse(json['release_date'] as String)
|
||||
: json['first_air_date'] != null && json['first_air_date'].isNotEmpty
|
||||
? DateTime.tryParse(json['first_air_date'] as String)
|
||||
: null,
|
||||
genres: List<String>.from(json['genres']?.map((g) => g['name']) ?? []),
|
||||
voteAverage: (json['vote_average'] as num?)?.toDouble() ?? 0.0,
|
||||
popularity: (json['popularity'] as num?)?.toDouble() ?? 0.0,
|
||||
runtime: json['runtime'] is num
|
||||
? (json['runtime'] as num).toInt()
|
||||
: (json['episode_run_time'] is List && (json['episode_run_time'] as List).isNotEmpty)
|
||||
? ((json['episode_run_time'] as List).first as num).toInt()
|
||||
: null,
|
||||
seasonsCount: json['number_of_seasons'] as int?,
|
||||
episodesCount: json['number_of_episodes'] as int?,
|
||||
tagline: json['tagline'] as String?,
|
||||
mediaType: (json['media_type'] ?? (json['title'] != null ? 'movie' : 'tv')) as String,
|
||||
);
|
||||
}
|
||||
|
||||
String get fullPosterUrl {
|
||||
final baseUrl = dotenv.env['API_URL']!;
|
||||
if (posterPath == null || posterPath!.isEmpty) {
|
||||
// Use the placeholder from our own backend
|
||||
return '$baseUrl/images/w500/placeholder.jpg';
|
||||
}
|
||||
// Null check is already performed above, so we can use `!`
|
||||
final cleanPath = posterPath!.startsWith('/') ? posterPath!.substring(1) : posterPath!;
|
||||
return '$baseUrl/images/w500/$cleanPath';
|
||||
}
|
||||
}
|
||||
59
lib/data/models/movie.g.dart
Normal file
59
lib/data/models/movie.g.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'movie.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class MovieAdapter extends TypeAdapter<Movie> {
|
||||
@override
|
||||
final int typeId = 0;
|
||||
|
||||
@override
|
||||
Movie read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return Movie(
|
||||
id: fields[0] as String,
|
||||
title: fields[1] as String,
|
||||
posterPath: fields[2] as String?,
|
||||
overview: fields[3] as String?,
|
||||
releaseDate: fields[4] as DateTime?,
|
||||
genres: (fields[5] as List?)?.cast<String>(),
|
||||
voteAverage: fields[6] as double?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, Movie obj) {
|
||||
writer
|
||||
..writeByte(7)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.title)
|
||||
..writeByte(2)
|
||||
..write(obj.posterPath)
|
||||
..writeByte(3)
|
||||
..write(obj.overview)
|
||||
..writeByte(4)
|
||||
..write(obj.releaseDate)
|
||||
..writeByte(5)
|
||||
..write(obj.genres)
|
||||
..writeByte(6)
|
||||
..write(obj.voteAverage);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is MovieAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
21
lib/data/models/movie_preview.dart
Normal file
21
lib/data/models/movie_preview.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'movie_preview.g.dart';
|
||||
|
||||
@HiveType(typeId: 1) // Use a new typeId to avoid conflicts with Movie
|
||||
class MoviePreview extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String title;
|
||||
|
||||
@HiveField(2)
|
||||
final String? posterPath;
|
||||
|
||||
MoviePreview({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.posterPath,
|
||||
});
|
||||
}
|
||||
47
lib/data/models/movie_preview.g.dart
Normal file
47
lib/data/models/movie_preview.g.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'movie_preview.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class MoviePreviewAdapter extends TypeAdapter<MoviePreview> {
|
||||
@override
|
||||
final int typeId = 1;
|
||||
|
||||
@override
|
||||
MoviePreview read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return MoviePreview(
|
||||
id: fields[0] as String,
|
||||
title: fields[1] as String,
|
||||
posterPath: fields[2] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, MoviePreview obj) {
|
||||
writer
|
||||
..writeByte(3)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.title)
|
||||
..writeByte(2)
|
||||
..write(obj.posterPath);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is MoviePreviewAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
80
lib/data/models/player/video_source.dart
Normal file
80
lib/data/models/player/video_source.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
enum VideoSourceType {
|
||||
lumex,
|
||||
alloha,
|
||||
}
|
||||
|
||||
class VideoSource extends Equatable {
|
||||
final String id;
|
||||
final String name;
|
||||
final VideoSourceType type;
|
||||
final bool isActive;
|
||||
|
||||
const VideoSource({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
this.isActive = true,
|
||||
});
|
||||
|
||||
// Default sources
|
||||
static final List<VideoSource> defaultSources = [
|
||||
const VideoSource(
|
||||
id: 'alloha',
|
||||
name: 'Alloha',
|
||||
type: VideoSourceType.alloha,
|
||||
isActive: true,
|
||||
),
|
||||
const VideoSource(
|
||||
id: 'lumex',
|
||||
name: 'Lumex',
|
||||
type: VideoSourceType.lumex,
|
||||
isActive: false,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, name, type, isActive];
|
||||
|
||||
@override
|
||||
bool get stringify => true;
|
||||
|
||||
// Convert to map for serialization
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'type': type.name,
|
||||
'isActive': isActive,
|
||||
};
|
||||
}
|
||||
|
||||
// Create from map for deserialization
|
||||
factory VideoSource.fromMap(Map<String, dynamic> map) {
|
||||
return VideoSource(
|
||||
id: map['id'] as String? ?? 'unknown',
|
||||
name: map['name'] as String? ?? 'Unknown',
|
||||
type: VideoSourceType.values.firstWhere(
|
||||
(e) => e.name == map['type'],
|
||||
orElse: () => VideoSourceType.lumex,
|
||||
),
|
||||
isActive: map['isActive'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
// Copy with method for immutability
|
||||
VideoSource copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
VideoSourceType? type,
|
||||
bool? isActive,
|
||||
}) {
|
||||
return VideoSource(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
isActive: isActive ?? this.isActive,
|
||||
);
|
||||
}
|
||||
}
|
||||
25
lib/data/models/reaction.dart
Normal file
25
lib/data/models/reaction.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
class Reaction {
|
||||
final String type;
|
||||
final int count;
|
||||
|
||||
Reaction({required this.type, required this.count});
|
||||
|
||||
factory Reaction.fromJson(Map<String, dynamic> json) {
|
||||
return Reaction(
|
||||
type: json['type'] as String? ?? '',
|
||||
count: json['count'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserReaction {
|
||||
final String? reactionType;
|
||||
|
||||
UserReaction({this.reactionType});
|
||||
|
||||
factory UserReaction.fromJson(Map<String, dynamic> json) {
|
||||
return UserReaction(
|
||||
reactionType: json['type'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
15
lib/data/models/user.dart
Normal file
15
lib/data/models/user.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
class User {
|
||||
final String id;
|
||||
final String name;
|
||||
final String email;
|
||||
|
||||
User({required this.id, required this.name, required this.email});
|
||||
|
||||
factory User.fromJson(Map<String, dynamic> json) {
|
||||
return User(
|
||||
id: json['_id'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
email: json['email'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
73
lib/data/repositories/auth_repository.dart
Normal file
73
lib/data/repositories/auth_repository.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:neomovies_mobile/data/api/api_client.dart';
|
||||
import 'package:neomovies_mobile/data/models/user.dart';
|
||||
import 'package:neomovies_mobile/data/services/secure_storage_service.dart';
|
||||
import 'package:neomovies_mobile/data/exceptions/auth_exceptions.dart';
|
||||
|
||||
class AuthRepository {
|
||||
final ApiClient _apiClient;
|
||||
final SecureStorageService _storageService;
|
||||
|
||||
AuthRepository({
|
||||
required ApiClient apiClient,
|
||||
required SecureStorageService storageService,
|
||||
}) : _apiClient = apiClient,
|
||||
_storageService = storageService;
|
||||
|
||||
Future<void> login(String email, String password) async {
|
||||
final response = await _apiClient.login(email, password);
|
||||
if (!response.verified) {
|
||||
throw UnverifiedAccountException(email, message: 'Account not verified');
|
||||
}
|
||||
await _storageService.saveToken(response.token);
|
||||
await _storageService.saveUserData(
|
||||
name: response.user.name,
|
||||
email: response.user.email,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> register(String name, String email, String password) async {
|
||||
// Registration does not automatically log in the user in this flow.
|
||||
// It sends a verification code.
|
||||
await _apiClient.register(name, email, password);
|
||||
}
|
||||
|
||||
Future<void> verifyEmail(String email, String code) async {
|
||||
await _apiClient.verify(email, code);
|
||||
// After successful verification, the user should log in.
|
||||
}
|
||||
|
||||
Future<void> resendVerificationCode(String email) async {
|
||||
await _apiClient.resendCode(email);
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _storageService.deleteAll();
|
||||
}
|
||||
|
||||
Future<void> deleteAccount() async {
|
||||
// The AuthenticatedHttpClient will handle the token.
|
||||
await _apiClient.deleteAccount();
|
||||
await _storageService.deleteAll();
|
||||
}
|
||||
|
||||
Future<bool> isLoggedIn() async {
|
||||
final token = await _storageService.getToken();
|
||||
return token != null;
|
||||
}
|
||||
|
||||
Future<User?> getCurrentUser() async {
|
||||
final isLoggedIn = await this.isLoggedIn();
|
||||
if (!isLoggedIn) return null;
|
||||
|
||||
final userData = await _storageService.getUserData();
|
||||
if (userData['name'] == null || userData['email'] == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The User model requires an ID, which we don't have in storage.
|
||||
// For the profile screen, we only need name and email.
|
||||
// We'll create a User object with a placeholder ID.
|
||||
return User(id: 'local', name: userData['name']!, email: userData['email']!);
|
||||
}
|
||||
}
|
||||
20
lib/data/repositories/favorites_repository.dart
Normal file
20
lib/data/repositories/favorites_repository.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:neomovies_mobile/data/api/api_client.dart';
|
||||
import 'package:neomovies_mobile/data/models/favorite.dart';
|
||||
|
||||
class FavoritesRepository {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
FavoritesRepository(this._apiClient);
|
||||
|
||||
Future<List<Favorite>> getFavorites() async {
|
||||
return await _apiClient.getFavorites();
|
||||
}
|
||||
|
||||
Future<void> addFavorite(String mediaId, String mediaType, String title, String posterPath) async {
|
||||
await _apiClient.addFavorite(mediaId, mediaType, title, posterPath);
|
||||
}
|
||||
|
||||
Future<void> removeFavorite(String mediaId) async {
|
||||
await _apiClient.removeFavorite(mediaId);
|
||||
}
|
||||
}
|
||||
33
lib/data/repositories/movie_repository.dart
Normal file
33
lib/data/repositories/movie_repository.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'package:neomovies_mobile/data/api/api_client.dart';
|
||||
import 'package:neomovies_mobile/data/models/movie.dart';
|
||||
import 'package:neomovies_mobile/data/models/movie_preview.dart';
|
||||
|
||||
class MovieRepository {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
MovieRepository({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||
|
||||
Future<List<Movie>> getPopularMovies({int page = 1}) async {
|
||||
return _apiClient.getPopularMovies(page: page);
|
||||
}
|
||||
|
||||
Future<List<Movie>> getTopRatedMovies({int page = 1}) async {
|
||||
return _apiClient.getTopRatedMovies(page: page);
|
||||
}
|
||||
|
||||
Future<List<Movie>> getUpcomingMovies({int page = 1}) async {
|
||||
return _apiClient.getUpcomingMovies(page: page);
|
||||
}
|
||||
|
||||
Future<Movie> getMovieById(String movieId) async {
|
||||
return _apiClient.getMovieById(movieId);
|
||||
}
|
||||
|
||||
Future<Movie> getTvById(String tvId) async {
|
||||
return _apiClient.getTvById(tvId);
|
||||
}
|
||||
|
||||
Future<List<Movie>> searchMovies(String query, {int page = 1}) async {
|
||||
return _apiClient.searchMovies(query, page: page);
|
||||
}
|
||||
}
|
||||
20
lib/data/repositories/reactions_repository.dart
Normal file
20
lib/data/repositories/reactions_repository.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:neomovies_mobile/data/api/api_client.dart';
|
||||
import 'package:neomovies_mobile/data/models/reaction.dart';
|
||||
|
||||
class ReactionsRepository {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
ReactionsRepository(this._apiClient);
|
||||
|
||||
Future<Map<String,int>> getReactionCounts(String mediaType,String mediaId) async {
|
||||
return await _apiClient.getReactionCounts(mediaType, mediaId);
|
||||
}
|
||||
|
||||
Future<UserReaction> getMyReaction(String mediaType,String mediaId) async {
|
||||
return await _apiClient.getMyReaction(mediaType, mediaId);
|
||||
}
|
||||
|
||||
Future<void> setReaction(String mediaType,String mediaId, String reactionType) async {
|
||||
await _apiClient.setReaction(mediaType, mediaId, reactionType);
|
||||
}
|
||||
}
|
||||
77
lib/data/services/alloha_player_service.dart
Normal file
77
lib/data/services/alloha_player_service.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
// lib/data/services/alloha_player_service.dart
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
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';
|
||||
|
||||
class AllohaPlayerService {
|
||||
static const String _baseUrl = 'https://neomovies.site'; // Replace with actual base URL
|
||||
|
||||
Future<Map<String, dynamic>> getStreamInfo(String mediaId, String mediaType) async {
|
||||
try {
|
||||
// First, get the player page
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/$mediaType/$mediaId/player'),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// Parse the response to extract stream information
|
||||
return _parsePlayerPage(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to load player page: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error getting stream info: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _parsePlayerPage(String html) {
|
||||
// TODO: Implement actual HTML parsing based on the Alloha player page structure
|
||||
// This is a placeholder - you'll need to update this based on the actual HTML structure
|
||||
|
||||
// Example structure (replace with actual parsing):
|
||||
return {
|
||||
'streamUrl': 'https://example.com/stream.m3u8',
|
||||
'qualities': [
|
||||
{'name': '1080p', 'resolution': '1920x1080', 'url': '...'},
|
||||
{'name': '720p', 'resolution': '1280x720', 'url': '...'},
|
||||
],
|
||||
'audioTracks': [
|
||||
{'id': 'ru', 'name': 'Русский', 'language': 'ru', 'isDefault': true},
|
||||
{'id': 'en', 'name': 'English', 'language': 'en'},
|
||||
],
|
||||
'subtitles': [
|
||||
{'id': 'ru', 'name': 'Русские', 'language': 'ru', 'url': '...'},
|
||||
{'id': 'en', 'name': 'English', 'language': 'en', 'url': '...'},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Convert parsed data to our models
|
||||
List<VideoQuality> parseQualities(List<dynamic> qualities) {
|
||||
return qualities.map((q) => VideoQuality(
|
||||
name: q['name'],
|
||||
resolution: q['resolution'],
|
||||
url: q['url'],
|
||||
)).toList();
|
||||
}
|
||||
|
||||
List<AudioTrack> parseAudioTracks(List<dynamic> tracks) {
|
||||
return tracks.map((t) => AudioTrack(
|
||||
id: t['id'],
|
||||
name: t['name'],
|
||||
language: t['language'],
|
||||
isDefault: t['isDefault'] ?? false,
|
||||
)).toList();
|
||||
}
|
||||
|
||||
List<Subtitle> parseSubtitles(List<dynamic> subtitles) {
|
||||
return subtitles.map((s) => Subtitle(
|
||||
id: s['id'],
|
||||
name: s['name'],
|
||||
language: s['language'],
|
||||
url: s['url'],
|
||||
)).toList();
|
||||
}
|
||||
}
|
||||
34
lib/data/services/secure_storage_service.dart
Normal file
34
lib/data/services/secure_storage_service.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class SecureStorageService {
|
||||
const SecureStorageService(this._storage);
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
Future<void> saveToken(String token) async {
|
||||
await _storage.write(key: 'auth_token', value: token);
|
||||
}
|
||||
|
||||
Future<String?> getToken() async {
|
||||
return await _storage.read(key: 'auth_token');
|
||||
}
|
||||
|
||||
Future<void> deleteToken() async {
|
||||
await _storage.delete(key: 'auth_token');
|
||||
}
|
||||
|
||||
Future<void> saveUserData({required String name, required String email}) async {
|
||||
await _storage.write(key: 'user_name', value: name);
|
||||
await _storage.write(key: 'user_email', value: email);
|
||||
}
|
||||
|
||||
Future<Map<String, String?>> getUserData() async {
|
||||
final name = await _storage.read(key: 'user_name');
|
||||
final email = await _storage.read(key: 'user_email');
|
||||
return {'name': name, 'email': email};
|
||||
}
|
||||
|
||||
Future<void> deleteAll() async {
|
||||
await _storage.deleteAll();
|
||||
}
|
||||
}
|
||||
99
lib/domain/repositories/movie_repository.dart
Normal file
99
lib/domain/repositories/movie_repository.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:neomovies_mobile/data/api/api_client.dart';
|
||||
import 'package:neomovies_mobile/data/models/movie.dart';
|
||||
import 'package:neomovies_mobile/data/models/movie_preview.dart';
|
||||
|
||||
class MovieRepository {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
static const String popularBox = 'popularMovies';
|
||||
static const String topRatedBox = 'topRatedMovies';
|
||||
static const String upcomingBox = 'upcomingMovies';
|
||||
|
||||
MovieRepository({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||
|
||||
Future<List<Movie>> getPopularMovies({int page = 1}) async {
|
||||
return _getCachedThenFetch(
|
||||
boxName: popularBox,
|
||||
fetch: () => _apiClient.getPopularMovies(page: page),
|
||||
page: page,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Movie>> getTopRatedMovies({int page = 1}) async {
|
||||
return _getCachedThenFetch(
|
||||
boxName: topRatedBox,
|
||||
fetch: () => _apiClient.getTopRatedMovies(page: page),
|
||||
page: page,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Movie>> getUpcomingMovies({int page = 1}) async {
|
||||
return _getCachedThenFetch(
|
||||
boxName: upcomingBox,
|
||||
fetch: () => _apiClient.getUpcomingMovies(page: page),
|
||||
page: page,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Movie> getMovieDetails(String id) async {
|
||||
// Caching for movie details can be added later if needed.
|
||||
return _apiClient.getMovieById(id);
|
||||
}
|
||||
|
||||
Future<List<Movie>> _getCachedThenFetch({
|
||||
required String boxName,
|
||||
required Future<List<Movie>> Function() fetch,
|
||||
required int page,
|
||||
}) async {
|
||||
final box = await Hive.openBox<MoviePreview>(boxName);
|
||||
|
||||
if (page == 1 && box.isNotEmpty) {
|
||||
final cachedPreviews = box.values.toList();
|
||||
// Convert cached previews to full Movie objects for the UI
|
||||
final cachedMovies = cachedPreviews
|
||||
.map((p) => Movie(id: p.id, title: p.title, posterPath: p.posterPath))
|
||||
.toList();
|
||||
|
||||
// Fetch new data in the background but don't wait for it here
|
||||
_fetchAndCache(box, fetch, page);
|
||||
return cachedMovies;
|
||||
}
|
||||
|
||||
// If no cache or not the first page, fetch from network
|
||||
final networkMovies = await _fetchAndCache(box, fetch, page);
|
||||
return networkMovies;
|
||||
}
|
||||
|
||||
Future<List<Movie>> _fetchAndCache(
|
||||
Box<MoviePreview> box,
|
||||
Future<List<Movie>> Function() fetch,
|
||||
int page,
|
||||
) async {
|
||||
try {
|
||||
final networkMovies = await fetch();
|
||||
if (page == 1) {
|
||||
await box.clear();
|
||||
for (var movie in networkMovies) {
|
||||
// Save the lightweight preview version to the cache
|
||||
final preview = MoviePreview(
|
||||
id: movie.id,
|
||||
title: movie.title,
|
||||
posterPath: movie.posterPath,
|
||||
);
|
||||
await box.put(preview.id, preview);
|
||||
}
|
||||
}
|
||||
return networkMovies;
|
||||
} catch (e) {
|
||||
if (page == 1 && box.isNotEmpty) {
|
||||
// If network fails, return data from cache
|
||||
final cachedPreviews = box.values.toList();
|
||||
return cachedPreviews
|
||||
.map((p) => Movie(id: p.id, title: p.title, posterPath: p.posterPath))
|
||||
.toList();
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
140
lib/main.dart
Normal file
140
lib/main.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:neomovies_mobile/data/api/api_client.dart';
|
||||
import 'package:neomovies_mobile/data/api/authenticated_http_client.dart';
|
||||
import 'package:neomovies_mobile/data/models/movie.dart';
|
||||
import 'package:neomovies_mobile/data/models/movie_preview.dart';
|
||||
import 'package:neomovies_mobile/data/repositories/auth_repository.dart';
|
||||
import 'package:neomovies_mobile/data/repositories/favorites_repository.dart';
|
||||
import 'package:neomovies_mobile/data/repositories/movie_repository.dart';
|
||||
import 'package:neomovies_mobile/data/repositories/reactions_repository.dart';
|
||||
import 'package:neomovies_mobile/data/services/secure_storage_service.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/home_provider.dart';
|
||||
import 'package:neomovies_mobile/presentation/providers/movie_detail_provider.dart';
|
||||
import 'package:neomovies_mobile/presentation/providers/reactions_provider.dart';
|
||||
import 'package:neomovies_mobile/presentation/screens/main_screen.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
// Ensure widgets are initialized
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Load environment variables
|
||||
await dotenv.load(fileName: ".env");
|
||||
|
||||
// Initialize Hive for local caching
|
||||
await Hive.initFlutter();
|
||||
|
||||
// Register Adapters
|
||||
Hive.registerAdapter(MovieAdapter());
|
||||
Hive.registerAdapter(MoviePreviewAdapter());
|
||||
|
||||
runApp(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
// Core Services & Clients
|
||||
Provider<FlutterSecureStorage>(create: (_) => const FlutterSecureStorage()),
|
||||
Provider<SecureStorageService>(
|
||||
create: (context) => SecureStorageService(context.read<FlutterSecureStorage>()),
|
||||
),
|
||||
Provider<http.Client>(create: (_) => http.Client()),
|
||||
Provider<AuthenticatedHttpClient>(
|
||||
create: (context) => AuthenticatedHttpClient(
|
||||
context.read<SecureStorageService>(),
|
||||
context.read<http.Client>(),
|
||||
),
|
||||
),
|
||||
Provider<ApiClient>(
|
||||
create: (context) => ApiClient(context.read<AuthenticatedHttpClient>()),
|
||||
),
|
||||
|
||||
// Repositories
|
||||
Provider<MovieRepository>(
|
||||
create: (context) => MovieRepository(apiClient: context.read<ApiClient>()),
|
||||
),
|
||||
Provider<AuthRepository>(
|
||||
create: (context) => AuthRepository(
|
||||
apiClient: context.read<ApiClient>(),
|
||||
storageService: context.read<SecureStorageService>(),
|
||||
),
|
||||
),
|
||||
Provider<FavoritesRepository>(
|
||||
create: (context) => FavoritesRepository(context.read<ApiClient>()),
|
||||
),
|
||||
Provider<ReactionsRepository>(
|
||||
create: (context) => ReactionsRepository(context.read<ApiClient>()),
|
||||
),
|
||||
|
||||
// State Notifiers (Providers)
|
||||
ChangeNotifierProvider<AuthProvider>(
|
||||
create: (context) => AuthProvider(authRepository: context.read<AuthRepository>()),
|
||||
),
|
||||
ChangeNotifierProvider<HomeProvider>(
|
||||
create: (context) => HomeProvider(movieRepository: context.read<MovieRepository>())..init(),
|
||||
),
|
||||
ChangeNotifierProvider<MovieDetailProvider>(
|
||||
create: (context) => MovieDetailProvider(
|
||||
context.read<MovieRepository>(),
|
||||
context.read<ApiClient>(),
|
||||
),
|
||||
),
|
||||
ChangeNotifierProvider<ReactionsProvider>(
|
||||
create: (context) => ReactionsProvider(
|
||||
context.read<ReactionsRepository>(),
|
||||
context.read<AuthProvider>(),
|
||||
)),
|
||||
ChangeNotifierProxyProvider<AuthProvider, FavoritesProvider>(
|
||||
create: (context) => FavoritesProvider(
|
||||
context.read<FavoritesRepository>(),
|
||||
context.read<AuthProvider>(),
|
||||
),
|
||||
update: (context, auth, previous) => previous!..update(auth),
|
||||
),
|
||||
],
|
||||
child: const MyApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
static final _defaultLightColorScheme = ColorScheme.fromSeed(seedColor: Colors.blue);
|
||||
static final _defaultDarkColorScheme = ColorScheme.fromSeed(seedColor: Colors.blue, brightness: Brightness.dark);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Use dynamic_color to get colors from the system
|
||||
return DynamicColorBuilder(
|
||||
builder: (lightColorScheme, darkColorScheme) {
|
||||
return MaterialApp(
|
||||
title: 'NeoMovies',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
colorScheme: lightColorScheme ?? _defaultLightColorScheme,
|
||||
useMaterial3: true,
|
||||
textTheme: GoogleFonts.manropeTextTheme(
|
||||
ThemeData(brightness: Brightness.light).textTheme,
|
||||
),
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: darkColorScheme ?? _defaultDarkColorScheme,
|
||||
useMaterial3: true,
|
||||
textTheme: GoogleFonts.manropeTextTheme(
|
||||
ThemeData(brightness: Brightness.dark).textTheme,
|
||||
),
|
||||
),
|
||||
themeMode: ThemeMode.system,
|
||||
home: const MainScreen(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
135
lib/presentation/providers/auth_provider.dart
Normal file
135
lib/presentation/providers/auth_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
94
lib/presentation/providers/favorites_provider.dart
Normal file
94
lib/presentation/providers/favorites_provider.dart
Normal 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();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
59
lib/presentation/providers/home_provider.dart
Normal file
59
lib/presentation/providers/home_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
254
lib/presentation/providers/licenses_provider.dart
Normal file
254
lib/presentation/providers/licenses_provider.dart
Normal 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 (_) {}
|
||||
}
|
||||
}
|
||||
60
lib/presentation/providers/movie_detail_provider.dart
Normal file
60
lib/presentation/providers/movie_detail_provider.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
91
lib/presentation/providers/movie_list_provider.dart
Normal file
91
lib/presentation/providers/movie_list_provider.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
368
lib/presentation/providers/player/player_provider.dart
Normal file
368
lib/presentation/providers/player/player_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
212
lib/presentation/providers/player/settings_provider.dart
Normal file
212
lib/presentation/providers/player/settings_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
105
lib/presentation/providers/reactions_provider.dart
Normal file
105
lib/presentation/providers/reactions_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
40
lib/presentation/providers/search_provider.dart
Normal file
40
lib/presentation/providers/search_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
221
lib/presentation/screens/auth/login_screen.dart
Normal file
221
lib/presentation/screens/auth/login_screen.dart
Normal 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
159
lib/presentation/screens/auth/profile_screen.dart
Normal file
159
lib/presentation/screens/auth/profile_screen.dart
Normal 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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
129
lib/presentation/screens/auth/verify_screen.dart
Normal file
129
lib/presentation/screens/auth/verify_screen.dart
Normal 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
121
lib/presentation/screens/favorites/favorites_screen.dart
Normal file
121
lib/presentation/screens/favorites/favorites_screen.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
162
lib/presentation/screens/home/home_screen.dart
Normal file
162
lib/presentation/screens/home/home_screen.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
108
lib/presentation/screens/main_screen.dart
Normal file
108
lib/presentation/screens/main_screen.dart
Normal 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',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
133
lib/presentation/screens/misc/licenses_screen.dart
Normal file
133
lib/presentation/screens/misc/licenses_screen.dart
Normal 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')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
356
lib/presentation/screens/movie_detail/movie_detail_screen.dart
Normal file
356
lib/presentation/screens/movie_detail/movie_detail_screen.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
98
lib/presentation/screens/movie_list_screen.dart
Normal file
98
lib/presentation/screens/movie_list_screen.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
189
lib/presentation/screens/player/video_player_screen.dart
Normal file
189
lib/presentation/screens/player/video_player_screen.dart
Normal 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
91
lib/presentation/screens/search/search_screen.dart
Normal file
91
lib/presentation/screens/search/search_screen.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
}(),
|
||||
);
|
||||
}
|
||||
}
|
||||
60
lib/presentation/widgets/movie_card.dart
Normal file
60
lib/presentation/widgets/movie_card.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
54
lib/presentation/widgets/movie_grid_item.dart
Normal file
54
lib/presentation/widgets/movie_grid_item.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
203
lib/presentation/widgets/player/web_player_widget.dart
Normal file
203
lib/presentation/widgets/player/web_player_widget.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
33
lib/utils/device_utils.dart
Normal file
33
lib/utils/device_utils.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DeviceUtils {
|
||||
/// Returns true if the device should be considered a tablet based on screen size.
|
||||
/// Uses 600dp shortestSide threshold which is a common heuristic.
|
||||
static bool isTablet(BuildContext context) {
|
||||
final shortestSide = MediaQuery.of(context).size.shortestSide;
|
||||
return shortestSide >= 600;
|
||||
}
|
||||
|
||||
/// Very naive Android TV detection. Treats a device as TV if it runs Android
|
||||
/// and has extremely large width (>= 950dp) and is in landscape.
|
||||
static bool isAndroidTv(BuildContext context) {
|
||||
if (defaultTargetPlatform != TargetPlatform.android) return false;
|
||||
final size = MediaQuery.of(context).size;
|
||||
return size.shortestSide >= 950 && size.aspectRatio > 1.4;
|
||||
}
|
||||
|
||||
static bool isLargeScreen(BuildContext context) {
|
||||
return isTablet(context) || isAndroidTv(context);
|
||||
}
|
||||
|
||||
/// Calculates responsive grid column count depending on screen width.
|
||||
static int calculateGridCount(BuildContext context) {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
if (width >= 1400) return 6;
|
||||
if (width >= 1200) return 5;
|
||||
if (width >= 900) return 4;
|
||||
if (width >= 600) return 3;
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user