mirror of
https://gitlab.com/foxixus/neomovies_mobile.git
synced 2025-10-28 05:58:50 +05:00
Initial commit
This commit is contained in:
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);
|
||||
},
|
||||
);
|
||||
}(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user