Initial commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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