mirror of
https://gitlab.com/foxixus/neomovies_mobile.git
synced 2025-10-28 01:18: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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user