diff --git a/assets/icon/Integrate-with-Samsung-IAP-05212021.pdf b/assets/icon/Integrate-with-Samsung-IAP-05212021.pdf deleted file mode 100644 index 38e5078..0000000 Binary files a/assets/icon/Integrate-with-Samsung-IAP-05212021.pdf and /dev/null differ diff --git a/assets/icon/app_icon.jpg b/assets/icon/app_icon.jpg deleted file mode 100644 index b214607..0000000 Binary files a/assets/icon/app_icon.jpg and /dev/null differ diff --git a/assets/icon/google.svg b/assets/icon/google.svg new file mode 100644 index 0000000..d5616b3 --- /dev/null +++ b/assets/icon/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/error/something_went_wrong.png b/assets/images/error/something_went_wrong.png deleted file mode 100644 index 53e8b63..0000000 Binary files a/assets/images/error/something_went_wrong.png and /dev/null differ diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..7e7e7f6 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1 @@ +extensions: diff --git a/lib/app.dart b/lib/app.dart index 7111d33..cb1f695 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:varanasi_mobile_app/features/library/cubit/library_cubit.dart'; +import 'package:varanasi_mobile_app/features/session/cubit/session_cubit.dart'; import 'package:varanasi_mobile_app/features/user-library/cubit/user_library_cubit.dart'; import 'package:varanasi_mobile_app/utils/constants/constants.dart'; import 'package:varanasi_mobile_app/utils/router.dart'; @@ -36,6 +37,7 @@ class Varanasi extends StatelessWidget { create: (ctx) => MediaPlayerCubit()..init(), ), BlocProvider(lazy: false, create: (ctx) => LibraryCubit()), + BlocProvider(lazy: false, create: (_) => SessionCubit()..init()), ], child: Builder(builder: (context) { final scheme = context.select( diff --git a/lib/cubits/player/player_cubit.dart b/lib/cubits/player/player_cubit.dart index 9b53db0..ebaf061 100644 --- a/lib/cubits/player/player_cubit.dart +++ b/lib/cubits/player/player_cubit.dart @@ -111,7 +111,10 @@ class MediaPlayerCubit extends AppCubit media.moreInfoUrl, options: CommonOptions( transformer: (response) { - final data = response as List; + if (response is! List) { + throw Exception('Invalid response'); + } + final data = response; if (data.isEmpty) { throw Exception('No data found'); } diff --git a/lib/features/home/bloc/home_state.dart b/lib/features/home/bloc/home_state.dart index 716fe00..0b19d3c 100644 --- a/lib/features/home/bloc/home_state.dart +++ b/lib/features/home/bloc/home_state.dart @@ -1,6 +1,6 @@ part of 'home_bloc.dart'; -abstract class HomeState extends Equatable { +sealed class HomeState extends Equatable { const HomeState(); } diff --git a/lib/features/home/ui/home_screen.dart b/lib/features/home/ui/home_screen.dart index 2bdc875..d3b17e9 100644 --- a/lib/features/home/ui/home_screen.dart +++ b/lib/features/home/ui/home_screen.dart @@ -54,10 +54,10 @@ class HomePage extends StatelessWidget { ], ), body: TriStateVisibility( - state: switch (state.runtimeType) { - HomeErrorState => TriState.error, - HomeLoadingState => TriState.loading, - _ => TriState.loaded, + state: switch (state) { + (HomeErrorState _) => TriState.error, + (HomeLoadingState _) => TriState.loading, + (_) => TriState.loaded, }, loadingChild: const HomePageLoader(), errorChild: switch (state) { diff --git a/lib/features/search/data/search_result/result.dart b/lib/features/search/data/search_result/result.dart index 863575c..83420e1 100644 --- a/lib/features/search/data/search_result/result.dart +++ b/lib/features/search/data/search_result/result.dart @@ -83,7 +83,7 @@ class Result extends PlayableMedia with EquatableMixin { String? get artworkUrl => image?.lastOrNull?.link ?? ''; @override - String get itemId => itemType.isSong + String get itemId => itemType.isSong && itemUrl.isNotEmpty ? (itemUrl.split('/').lastOrNull ?? id ?? '') : (id ?? ''); @@ -98,7 +98,4 @@ class Result extends PlayableMedia with EquatableMixin { @override String get itemUrl => url ?? ''; - - @override - bool get preferLinkOverId => itemType.isSong || itemType.isAlbum; } diff --git a/lib/features/search/data/top_search_result/top_search.dart b/lib/features/search/data/top_search_result/top_search.dart index 4314c8a..6995b68 100644 --- a/lib/features/search/data/top_search_result/top_search.dart +++ b/lib/features/search/data/top_search_result/top_search.dart @@ -70,7 +70,9 @@ class TopSearch extends PlayableMedia with EquatableMixin { String? get artworkUrl => image?.lastOrNull?.link; @override - String get itemId => itemUrl.split('/').lastOrNull ?? id ?? ''; + String get itemId => itemUrl.isEmpty + ? (id ?? '') + : (itemUrl.split('/').lastOrNull ?? id ?? ''); @override String get itemSubtitle => "Top Search • $description"; @@ -83,7 +85,4 @@ class TopSearch extends PlayableMedia with EquatableMixin { @override String get itemUrl => url ?? ''; - - @override - bool get preferLinkOverId => true; } diff --git a/lib/features/session/cubit/session_cubit.dart b/lib/features/session/cubit/session_cubit.dart index 0bc711d..3c947c1 100644 --- a/lib/features/session/cubit/session_cubit.dart +++ b/lib/features/session/cubit/session_cubit.dart @@ -2,13 +2,10 @@ import 'dart:async'; import 'package:equatable/equatable.dart'; import 'package:firebase_auth/firebase_auth.dart'; -import 'package:go_router/go_router.dart'; import 'package:google_sign_in/google_sign_in.dart'; import 'package:varanasi_mobile_app/utils/app_cubit.dart'; -import 'package:varanasi_mobile_app/utils/extensions/router.dart'; -import 'package:varanasi_mobile_app/utils/helpers/get_app_context.dart'; +import 'package:varanasi_mobile_app/utils/app_snackbar.dart'; import 'package:varanasi_mobile_app/utils/logger.dart'; -import 'package:varanasi_mobile_app/utils/routes.dart'; part 'session_state.dart'; @@ -23,22 +20,9 @@ class SessionCubit extends AppCubit { @override FutureOr init() { - _auth.userChanges().listen((user) { - if (user == null) { - emit(UnAuthenticated()); - } else { - emit(Authenticated(user: user)); - } - }); - stream.distinct().listen((state) { - if (state is! Authenticated) { - return appContext.go(AppRoutes.authentication.path); - } - if ([AppRoutes.authentication.path] - .contains(appContext.routerState.matchedLocation)) { - return appContext.go(AppRoutes.home.path); - } - }); + _auth.userChanges().map((user) { + return user == null ? UnAuthenticated() : Authenticated(user: user); + }).listen(emit); } Future continueWithGoogle() async { @@ -52,8 +36,78 @@ class SessionCubit extends AppCubit { ); final userCredential = await _auth.signInWithCredential(credential); _logger.d(userCredential.user?.toString()); + } on FirebaseAuthException catch (e) { + _handleException(e); } catch (e) { + await _googleSignIn.signOut(); _logger.d(e.toString()); } } + + Future signInWithEmailAndPassword({ + required String email, + required String password, + }) async { + emit(Authenticating()); + try { + final userCredential = await _auth.signInWithEmailAndPassword( + email: email, + password: password, + ); + _logger.d(userCredential.user?.toString()); + } on FirebaseAuthException catch (e) { + _handleException(e); + } catch (e) { + _logger.d(e.toString()); + } + } + + Future signUpWithEmailAndPassword({ + required String email, + required String password, + required String name, + }) async { + emit(Authenticating()); + try { + final userCredential = await _auth.createUserWithEmailAndPassword( + email: email, + password: password, + ); + await userCredential.user?.updateDisplayName(name); + _logger.d(userCredential.user?.toString()); + } on FirebaseAuthException catch (e) { + _handleException(e); + } catch (e) { + _logger.d(e.toString()); + } + } + + Future signOut() async { + emit(Authenticating()); + try { + await _googleSignIn.signOut(); + await _auth.signOut(); + } catch (e) { + _logger.d(e.toString()); + } + } + + void _handleException(FirebaseAuthException exception) { + final message = switch (exception.code) { + 'invalid-email' => 'The email address is not valid.', + 'user-disabled' => + 'The user corresponding to the given email has been disabled.', + 'user-not-found' => + 'The user corresponding to the given email does not exist.', + 'wrong-password' => + 'The password is invalid for the given email, or the account corresponding to the email does not have a password set.', + 'email-already-in-use' => + 'The email address is already in use by another account.', + 'operation-not-allowed' => + 'Indicates that Email & Password accounts are not enabled.', + 'weak-password' => 'The password must be 6 characters long or more.', + (_) => 'An undefined Error happened.' + }; + AppSnackbar.show(message); + } } diff --git a/lib/features/session/ui/auth_page.dart b/lib/features/session/ui/auth_page.dart index d452b31..a730576 100644 --- a/lib/features/session/ui/auth_page.dart +++ b/lib/features/session/ui/auth_page.dart @@ -1,81 +1,77 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:varanasi_mobile_app/features/session/cubit/session_cubit.dart'; import 'package:varanasi_mobile_app/gen/assets.gen.dart'; import 'package:varanasi_mobile_app/utils/extensions/extensions.dart'; import 'package:varanasi_mobile_app/utils/helpers/ressponsive_sizer.dart'; +import 'package:varanasi_mobile_app/utils/routes.dart'; class AuthPage extends StatelessWidget { const AuthPage({super.key}); @override Widget build(BuildContext context) { - return AnnotatedRegion( - value: const SystemUiOverlayStyle(statusBarBrightness: Brightness.dark), - child: Scaffold( - body: SafeArea( - child: Align( - alignment: Alignment.center, - child: SizedBox( - width: Device.width * 0.8, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Assets.icon.appIconMonotone.svg( - placeholderBuilder: (ctx) => - const SizedBox(width: 48, height: 48), + return Scaffold( + body: SafeArea( + child: Align( + alignment: Alignment.center, + child: SizedBox( + width: Device.width * 0.8, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Assets.icon.appIconMonotone.svg( + placeholderBuilder: (ctx) => + const SizedBox(width: 48, height: 48), + ), + const SizedBox(height: 20), + Text( + "Millions of Songs.\nForever Free.", + style: context.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, ), - const SizedBox(height: 20), - Text( - "Millions of Songs.\nForever Free.", - style: context.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, + textAlign: TextAlign.center, + ), + const SizedBox(height: 36), + FilledButton.tonal( + style: _buildButtonStyles(), + onPressed: () => context.pushNamed(AppRoutes.signup.name), + child: _buildText(context, "Sign up for free"), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + icon: Assets.icon.google.svg(width: 24, height: 24), + onPressed: context.read().continueWithGoogle, + style: _buildButtonStyles(), + label: Center( + child: _buildText(context, "Continue with Google"), ), - const SizedBox(height: 36), - FilledButton.tonal( - onPressed: () {}, - child: const Text( - "Sign up for free", - style: TextStyle( - fontWeight: FontWeight.bold, - ), - ), - ), - OutlinedButton.icon( - icon: const Icon(Icons.facebook), - onPressed: context.read().continueWithGoogle, - style: OutlinedButton.styleFrom( - foregroundColor: context.colorScheme.onBackground, - ), - label: const Center( - child: Text( - "Continue with Google", - style: TextStyle( - fontWeight: FontWeight.w800, - ), - ), - ), - ), - TextButton( - style: TextButton.styleFrom( - foregroundColor: context.colorScheme.onBackground, - ), - onPressed: () {}, - child: const Text( - "Log in", - style: TextStyle(fontWeight: FontWeight.bold), - ), - ) - ], - ), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => context.pushNamed(AppRoutes.login.name), + child: _buildText(context, "Log in"), + ) + ], ), ), ), ), ); } + + ButtonStyle _buildButtonStyles() { + return OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + ); + } + + Text _buildText(BuildContext context, String text) { + return Text( + text, + style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold), + ); + } } diff --git a/lib/features/session/ui/login_page.dart b/lib/features/session/ui/login_page.dart new file mode 100644 index 0000000..ac6cce2 --- /dev/null +++ b/lib/features/session/ui/login_page.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:varanasi_mobile_app/features/session/cubit/session_cubit.dart'; +import 'package:varanasi_mobile_app/utils/extensions/extensions.dart'; +import 'package:varanasi_mobile_app/utils/extensions/media_query.dart'; +import 'package:varanasi_mobile_app/widgets/input_field.dart'; + +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + late final GlobalKey _formKey; + late final TextEditingController _emailController; + late final TextEditingController _passwordController; + bool isFormValid = false; + bool isPasswordVisible = false; + + @override + void initState() { + _formKey = GlobalKey(); + _emailController = TextEditingController(); + _passwordController = TextEditingController(); + super.initState(); + } + + @override + void dispose() { + _formKey.currentState?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + centerTitle: true, + title: const Text('Log in'), + titleTextStyle: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + body: SizedBox( + width: context.width, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: _buildForm(context), + ), + ), + ); + } + + Form _buildForm(BuildContext context) { + return Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + onChanged: () { + final isValid = _formKey.currentState?.validate() ?? false; + if (isValid != isFormValid) { + setState(() { + isFormValid = isValid; + }); + } + }, + child: AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Email', + style: context.textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + FilledInputField( + controller: _emailController, + context: context, + maxLines: 1, + autofillHints: const [AutofillHints.email], + autofocus: true, + inputType: InputType.email, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 24), + Text( + 'Password', + style: context.textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + FilledInputField( + controller: _passwordController, + context: context, + autofillHints: const [AutofillHints.password], + inputType: InputType.password, + maxLines: 1, + obscureText: !isPasswordVisible, + decoration: InputDecoration(suffixIcon: _buildSuffix()), + ), + const SizedBox(height: 24), + LoginButton( + isFormValid: isFormValid, + emailController: _emailController, + passwordController: _passwordController, + ), + ], + ), + ), + ); + } + + GestureDetector _buildSuffix() { + return GestureDetector( + onTap: () => setState(() { + isPasswordVisible = !isPasswordVisible; + }), + child: Icon( + isPasswordVisible + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 24, + ), + ); + } +} + +class LoginButton extends StatelessWidget { + const LoginButton({ + super.key, + required this.isFormValid, + required TextEditingController emailController, + required TextEditingController passwordController, + }) : _emailController = emailController, + _passwordController = passwordController; + + final bool isFormValid; + final TextEditingController _emailController; + final TextEditingController _passwordController; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.center, + child: FilledButton.tonal( + onPressed: isFormValid + ? () { + context.read().signInWithEmailAndPassword( + email: _emailController.text, + password: _passwordController.text, + ); + } + : null, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 36, + ), + ), + child: const Text( + 'Log in', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ); + } +} diff --git a/lib/features/session/ui/signup_page.dart b/lib/features/session/ui/signup_page.dart new file mode 100644 index 0000000..15f8ad5 --- /dev/null +++ b/lib/features/session/ui/signup_page.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:varanasi_mobile_app/features/session/cubit/session_cubit.dart'; +import 'package:varanasi_mobile_app/utils/extensions/extensions.dart'; +import 'package:varanasi_mobile_app/widgets/input_field.dart'; + +class SignupPage extends StatefulWidget { + const SignupPage({super.key}); + + @override + State createState() => _SignupPageState(); +} + +class _SignupPageState extends State { + late final GlobalKey _formKey; + late final TextEditingController _nameController; + late final TextEditingController _emailController; + late final TextEditingController _passwordController; + bool isFormValid = false; + bool isPasswordVisible = false; + + @override + void initState() { + _formKey = GlobalKey(); + _nameController = TextEditingController(); + _emailController = TextEditingController(); + _passwordController = TextEditingController(); + super.initState(); + } + + @override + void dispose() { + _formKey.currentState?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + centerTitle: true, + title: const Text('Create Account'), + titleTextStyle: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + body: SizedBox( + width: context.width, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: _buildForm(context), + ), + ), + ); + } + + Form _buildForm(BuildContext context) { + return Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + onChanged: () { + final isValid = _formKey.currentState?.validate() ?? false; + if (isValid != isFormValid) { + setState(() { + isFormValid = isValid; + }); + } + }, + child: AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(context, 'What should we call you?'), + const SizedBox(height: 8), + FilledInputField( + controller: _nameController, + context: context, + maxLines: 1, + autofillHints: const [ + AutofillHints.name, + AutofillHints.newUsername + ], + autofocus: true, + inputType: InputType.username, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 24), + _buildTitle(context, 'What\'s your email?'), + const SizedBox(height: 8), + FilledInputField( + controller: _emailController, + context: context, + maxLines: 1, + autofillHints: const [AutofillHints.email], + inputType: InputType.email, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 24), + _buildTitle(context, 'Create a password'), + const SizedBox(height: 8), + FilledInputField( + controller: _passwordController, + context: context, + autofillHints: const [AutofillHints.newPassword], + inputType: InputType.password, + maxLines: 1, + obscureText: !isPasswordVisible, + decoration: InputDecoration(suffixIcon: _buildSuffix()), + ), + const SizedBox(height: 24), + LoginButton( + isFormValid: isFormValid, + emailController: _emailController, + passwordController: _passwordController, + nameController: _nameController, + ), + ], + ), + ), + ); + } + + Text _buildTitle(BuildContext context, String text) { + return Text( + text, + style: context.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ); + } + + GestureDetector _buildSuffix() { + return GestureDetector( + onTap: () => setState(() { + isPasswordVisible = !isPasswordVisible; + }), + child: Icon( + isPasswordVisible + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 24, + ), + ); + } +} + +class LoginButton extends StatelessWidget { + const LoginButton({ + super.key, + required this.isFormValid, + required this.emailController, + required this.passwordController, + required this.nameController, + }); + + final bool isFormValid; + final TextEditingController emailController; + final TextEditingController passwordController; + final TextEditingController nameController; + + @override + Widget build(BuildContext context) { + final authenticating = context.select((SessionCubit cubit) => + cubit.state is Authenticating || cubit.state is Authenticated); + return Align( + alignment: Alignment.center, + child: FilledButton.tonal( + onPressed: isFormValid && !authenticating + ? () { + context.read().signUpWithEmailAndPassword( + email: emailController.text, + password: passwordController.text, + name: nameController.text, + ); + } + : null, + style: FilledButton.styleFrom( + padding: authenticating + ? const EdgeInsets.symmetric( + vertical: 12, + horizontal: 12, + ) + : const EdgeInsets.symmetric( + vertical: 12, + horizontal: 36, + ), + ), + child: Visibility( + visible: !authenticating, + replacement: const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(), + ), + child: const Text( + 'Create Account', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + ); + } +} diff --git a/lib/features/settings/ui/settings_page.dart b/lib/features/settings/ui/settings_page.dart index c205b3e..a192a24 100644 --- a/lib/features/settings/ui/settings_page.dart +++ b/lib/features/settings/ui/settings_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:settings_ui/settings_ui.dart'; import 'package:varanasi_mobile_app/cubits/config/config_cubit.dart'; +import 'package:varanasi_mobile_app/features/session/cubit/session_cubit.dart'; import 'package:varanasi_mobile_app/flavors.dart'; import 'package:varanasi_mobile_app/models/app_config.dart'; import 'package:varanasi_mobile_app/models/download_url.dart'; @@ -28,6 +29,18 @@ class SettingsPage extends StatelessWidget { appBar: AppBar(title: const Text("Settings"), centerTitle: true), body: SettingsList( sections: [ + SettingsSection( + title: const Text("Account"), + tiles: [ + SettingsTile( + title: const Text("Sign out"), + leading: const Icon(Icons.logout_outlined), + onPressed: (context) { + context.read().signOut(); + }, + ), + ], + ), SettingsSection( title: const Text("General"), tiles: [ diff --git a/lib/gen/assets.gen.dart b/lib/gen/assets.gen.dart index 46290fc..519ec7e 100644 --- a/lib/gen/assets.gen.dart +++ b/lib/gen/assets.gen.dart @@ -15,13 +15,6 @@ import 'package:lottie/lottie.dart'; class $AssetsIconGen { const $AssetsIconGen(); - /// File path: assets/icon/Integrate-with-Samsung-IAP-05212021.pdf - String get integrateWithSamsungIAP05212021 => - 'assets/icon/Integrate-with-Samsung-IAP-05212021.pdf'; - - /// File path: assets/icon/app_icon.jpg - AssetGenImage get appIcon => const AssetGenImage('assets/icon/app_icon.jpg'); - /// File path: assets/icon/app_icon_monotone.svg SvgGenImage get appIconMonotone => const SvgGenImage('assets/icon/app_icon_monotone.svg'); @@ -41,6 +34,9 @@ class $AssetsIconGen { SvgGenImage get downloading => const SvgGenImage('assets/icon/downloading.svg'); + /// File path: assets/icon/google.svg + SvgGenImage get google => const SvgGenImage('assets/icon/google.svg'); + $AssetsIconNavGen get nav => const $AssetsIconNavGen(); /// File path: assets/icon/shuffle.svg @@ -48,21 +44,18 @@ class $AssetsIconGen { /// List of all assets List get values => [ - integrateWithSamsungIAP05212021, - appIcon, appIconMonotone, circularLoader, download, downloadFilled, downloading, + google, shuffle ]; } class $AssetsImagesGen { const $AssetsImagesGen(); - - $AssetsImagesErrorGen get error => const $AssetsImagesErrorGen(); } class $AssetsIconNavGen { @@ -94,17 +87,6 @@ class $AssetsIconNavGen { [home, homeSelected, library, librarySelected, search, searchSelected]; } -class $AssetsImagesErrorGen { - const $AssetsImagesErrorGen(); - - /// File path: assets/images/error/something_went_wrong.png - AssetGenImage get somethingWentWrong => - const AssetGenImage('assets/images/error/something_went_wrong.png'); - - /// List of all assets - List get values => [somethingWentWrong]; -} - class Assets { Assets._(); diff --git a/lib/models/playable_item.dart b/lib/models/playable_item.dart index 2971803..a3adf1f 100644 --- a/lib/models/playable_item.dart +++ b/lib/models/playable_item.dart @@ -2,7 +2,6 @@ import 'package:audio_service/audio_service.dart'; import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; -import 'package:flutter/foundation.dart'; import 'package:varanasi_mobile_app/models/app_config.dart'; import 'package:varanasi_mobile_app/models/download_url.dart'; import 'package:varanasi_mobile_app/models/image.dart'; @@ -41,7 +40,8 @@ abstract class PlayableMedia extends Equatable { String get heroTag => itemId; String get itemSubtitle; - bool get preferLinkOverId => false; + bool get preferLinkOverId => + (itemType.isSong || itemType.isAlbum) && itemUrl.isNotEmpty; PlayableMediaType get itemType; String? get artworkUrl; @@ -111,16 +111,19 @@ abstract class PlayableMedia extends Equatable { Uri get moreInfoUrl { return switch (itemType) { PlayableMediaType.song when !preferLinkOverId => Uri.parse( - '${appConfig.endpoint.songs!.id}?id=$itemId&language=hindi,english', + '${appConfig.endpoint.songs!.id}?id=$itemId', ), PlayableMediaType.song => Uri.parse( - '${appConfig.endpoint.songs!.link}?link=${Uri.encodeComponent(itemUrl)}&language=hindi,english', + '${appConfig.endpoint.songs!.link}?link=${Uri.encodeComponent(itemUrl)}', + ), + PlayableMediaType.album when preferLinkOverId => Uri.parse( + '${appConfig.endpoint.albums!.link}?link=$itemUrl', ), PlayableMediaType.album => Uri.parse( - '${appConfig.endpoint.albums!.link}?link=$itemUrl&language=hindi,english', + '${appConfig.endpoint.albums!.id}?id=$itemId', ), PlayableMediaType.playlist => Uri.parse( - '${appConfig.endpoint.playlists!.id}?id=$itemId&language=hindi,english', + '${appConfig.endpoint.playlists!.id}?id=$itemId', ), PlayableMediaType.artist => Uri.parse( '${appConfig.endpoint.artists?.id}?id=$itemId&language=hindi,english', @@ -131,7 +134,7 @@ abstract class PlayableMedia extends Equatable { /// {@template getCacheKey} /// Returns a unique key for the [PlayableMedia] to be used in the cache. /// {@endtemplate} - String get cacheKey => '$itemId-${describeEnum(itemType)}'; + String get cacheKey => '$itemId-${itemType.name}'; MediaPlaylist toMediaPlaylist() { return MediaPlaylist( diff --git a/lib/utils/configs.dart b/lib/utils/configs.dart index dffdab6..12892f1 100644 --- a/lib/utils/configs.dart +++ b/lib/utils/configs.dart @@ -31,9 +31,11 @@ class Songs { class Albums { final String link; + final String id; const Albums({ required this.link, + required this.id, }); } @@ -119,7 +121,7 @@ Config get appConfig { endpoint: Endpoint( modules: '/modules', playlists: Playlists(id: 'playlists'), - albums: Albums(link: 'albums'), + albums: Albums(id: 'albums', link: 'albums'), songs: Songs(id: 'songs', link: 'songs'), search: Search( all: "/search/all", diff --git a/lib/utils/extensions/extensions.dart b/lib/utils/extensions/extensions.dart index 5aacfec..ed32590 100644 --- a/lib/utils/extensions/extensions.dart +++ b/lib/utils/extensions/extensions.dart @@ -1,5 +1,6 @@ library extensions; export 'color.dart'; +export 'media_query.dart'; export 'strings.dart'; export 'theme.dart'; diff --git a/lib/utils/generate_greeting.dart b/lib/utils/generate_greeting.dart index 8be0157..421296e 100644 --- a/lib/utils/generate_greeting.dart +++ b/lib/utils/generate_greeting.dart @@ -1,11 +1,12 @@ +const greetings = [ + 'Good morning!', + 'Good afternoon!', + 'Good evening!', + 'Hello!' +]; + String generateGreeting() { var hour = DateTime.now().hour; - var greetings = [ - 'Good morning!', - 'Good afternoon!', - 'Good evening!', - 'Hello!' - ]; if (hour < 12) { return greetings[0]; } else if (hour < 18) { diff --git a/lib/utils/input_validators.dart b/lib/utils/input_validators.dart new file mode 100644 index 0000000..cba1867 --- /dev/null +++ b/lib/utils/input_validators.dart @@ -0,0 +1,54 @@ +class InputValidators { + static String? validateName(String? value) { + // name should be at least 2 characters long + if (value == null || value.isEmpty) { + return 'Name is required'; + } + if (value.length < 2) { + return 'Name should be at least 2 characters long'; + } + // name should not contain any numbers or special characters + if (value.contains(RegExp(r'[0-9!@#\$&*~]'))) { + return 'Name should not contain any numbers or special characters'; + } + return null; + } + + static String? validateEmail(String? value) { + // email should be in the format of an email address + // make sure emails in format `john.doe+1@gmail.com` and `john.doe@gmail.com` both are accepted + final regex = RegExp( + r'^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$'); + if (value == null || value.isEmpty) { + return 'Email is required'; + } + if (!regex.hasMatch(value)) { + return 'Enter a valid email address'; + } + return null; + } + + static String? validatePassword(String? value) { + // password should be at least 8 characters long, contain at least one uppercase letter, one lowercase letter, one number and one special character + // do separate checks for each requirement + if (value == null || value.isEmpty) { + return 'Password is required'; + } + if (value.length < 8) { + return 'Password should be at least 8 characters long'; + } + if (!value.contains(RegExp(r'[A-Z]'))) { + return 'Password should contain at least one uppercase letter'; + } + if (!value.contains(RegExp(r'[a-z]'))) { + return 'Password should contain at least one lowercase letter'; + } + if (!value.contains(RegExp(r'[0-9]'))) { + return 'Password should contain at least one number'; + } + if (!value.contains(RegExp(r'[!@#\$&*~]'))) { + return 'Password should contain at least one special character'; + } + return null; + } +} diff --git a/lib/utils/router.dart b/lib/utils/router.dart index d031662..af77279 100644 --- a/lib/utils/router.dart +++ b/lib/utils/router.dart @@ -1,3 +1,7 @@ +import 'dart:async'; + +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:varanasi_mobile_app/features/home/bloc/home_bloc.dart'; @@ -7,7 +11,10 @@ import 'package:varanasi_mobile_app/features/library/ui/library_screen.dart'; import 'package:varanasi_mobile_app/features/library/ui/library_search_page.dart'; import 'package:varanasi_mobile_app/features/search/cubit/search_cubit.dart'; import 'package:varanasi_mobile_app/features/search/ui/search_page.dart'; +import 'package:varanasi_mobile_app/features/session/cubit/session_cubit.dart'; import 'package:varanasi_mobile_app/features/session/ui/auth_page.dart'; +import 'package:varanasi_mobile_app/features/session/ui/login_page.dart'; +import 'package:varanasi_mobile_app/features/session/ui/signup_page.dart'; import 'package:varanasi_mobile_app/features/settings/ui/settings_page.dart'; import 'package:varanasi_mobile_app/features/user-library/data/user_library.dart'; import 'package:varanasi_mobile_app/features/user-library/ui/user_library_page.dart'; @@ -19,9 +26,43 @@ import 'package:varanasi_mobile_app/widgets/transitions/fade_transition_page.dar import 'keys.dart'; +class StreamListener extends ChangeNotifier { + /// Creates a [StreamListener]. + /// + /// Every time the [Stream] receives an event this [ChangeNotifier] will + /// notify its listeners. + StreamListener(Stream stream) { + notifyListeners(); + _subscription = stream.asBroadcastStream().listen((_) => notifyListeners()); + } + + late final StreamSubscription _subscription; + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } +} + final routerConfig = GoRouter( initialLocation: AppRoutes.home.path, navigatorKey: rootNavigatorKey, + refreshListenable: StreamListener(FirebaseAuth.instance.userChanges()), + redirect: (context, state) { + final session = context.read().state; + final allowedLoggedOutRoutes = [ + AppRoutes.authentication.name, + AppRoutes.login.name, + AppRoutes.signup.name, + ].map(state.namedLocation); + final isInsideAuth = allowedLoggedOutRoutes.contains(state.matchedLocation); + return switch (session) { + (UnAuthenticated _) when !isInsideAuth => AppRoutes.authentication.path, + (Authenticated _) when isInsideAuth => AppRoutes.home.path, + _ => null, + }; + }, routes: [ StatefulShellRoute.indexedStack( parentNavigatorKey: rootNavigatorKey, @@ -117,6 +158,18 @@ final routerConfig = GoRouter( name: AppRoutes.authentication.name, path: AppRoutes.authentication.path, builder: (context, state) => AuthPage(key: state.pageKey), + routes: [ + GoRoute( + name: AppRoutes.login.name, + path: AppRoutes.login.path, + builder: (context, state) => LoginPage(key: state.pageKey), + ), + GoRoute( + path: AppRoutes.signup.path, + name: AppRoutes.signup.name, + builder: (context, state) => SignupPage(key: state.pageKey), + ) + ], ), ], ); diff --git a/lib/utils/routes.dart b/lib/utils/routes.dart index 19a17ef..c0d4a8e 100644 --- a/lib/utils/routes.dart +++ b/lib/utils/routes.dart @@ -5,6 +5,8 @@ class AppRoutes { static const Route library = (name: 'media-library', path: '/media-library'); static const Route librarySearch = (name: 'library-search', path: 'search'); static const Route authentication = (name: 'auth', path: '/auth'); + static const Route login = (name: 'login', path: 'login'); + static const Route signup = (name: 'signup', path: 'signup'); static const Route splash = (name: 'splash', path: '/splash'); // home specific routes diff --git a/lib/widgets/animated_overflow_text.dart b/lib/widgets/animated_overflow_text.dart index 4e1385a..14e3887 100644 --- a/lib/widgets/animated_overflow_text.dart +++ b/lib/widgets/animated_overflow_text.dart @@ -26,7 +26,6 @@ class AnimatedText extends StatelessWidget { this.wrapWords = true, this.overflow, this.overflowReplacement, - this.textScaleFactor, this.maxLines, this.semanticsLabel, }) : textSpan = null; @@ -51,7 +50,6 @@ class AnimatedText extends StatelessWidget { this.wrapWords = true, this.overflow, this.overflowReplacement, - this.textScaleFactor, this.maxLines, this.semanticsLabel, }) : data = null; @@ -174,18 +172,6 @@ class AnimatedText extends StatelessWidget { /// displayed instead. final Widget? overflowReplacement; - /// The number of font pixels for each logical pixel. - /// - /// For example, if the text scale factor is 1.5, text will be 50% larger than - /// the specified font size. - /// - /// This property also affects [minFontSize], [maxFontSize] and [presetFontSizes]. - /// - /// The value given to the constructor as textScaleFactor. If null, will - /// use the [MediaQueryData.textScaleFactor] obtained from the ambient - /// [MediaQuery], or 1.0 if there is no [MediaQuery] in scope. - final double? textScaleFactor; - /// An optional maximum number of lines for the text to span, wrapping if necessary. /// If the text exceeds the given number of lines, it will be resized according /// to the specified bounds and if necessary truncated according to [overflow]. @@ -249,7 +235,6 @@ class AnimatedText extends StatelessWidget { overflow: overflow, overflowReplacement: overflowReplacement ?? buildDefaultOverfowReplacement(), - textScaleFactor: textScaleFactor, maxLines: maxLines, semanticsLabel: semanticsLabel, ); @@ -273,7 +258,6 @@ class AnimatedText extends StatelessWidget { overflow: overflow, overflowReplacement: overflowReplacement ?? buildDefaultOverfowReplacement(), - textScaleFactor: textScaleFactor, maxLines: maxLines, semanticsLabel: semanticsLabel, ); diff --git a/lib/widgets/error/error_page.dart b/lib/widgets/error/error_page.dart index dd3d43d..3a5fd1e 100644 --- a/lib/widgets/error/error_page.dart +++ b/lib/widgets/error/error_page.dart @@ -18,8 +18,8 @@ class ErrorPage extends StatelessWidget { @override Widget build(BuildContext context) { - return switch (error.runtimeType) { - NetworkException => NetworkErrorPage( + return switch (error) { + (NetworkException _) => NetworkErrorPage( error: error as NetworkException, retryCallback: retryCallback, ), diff --git a/lib/widgets/error/network_error.dart b/lib/widgets/error/network_error.dart index 788ae5f..da1b278 100644 --- a/lib/widgets/error/network_error.dart +++ b/lib/widgets/error/network_error.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:varanasi_mobile_app/gen/assets.gen.dart'; import 'package:varanasi_mobile_app/utils/exceptions/network_exception.dart'; -import 'package:varanasi_mobile_app/utils/extensions/ressponsive_sizer.dart'; +import 'package:varanasi_mobile_app/utils/extensions/extensions.dart'; import 'error_page.dart'; @@ -18,23 +17,29 @@ class NetworkErrorPage extends ErrorPage { @override Widget build(BuildContext context) { return Scaffold( - body: Stack( - children: [ - Assets.images.error.somethingWentWrong.image( - fit: BoxFit.cover, - height: double.infinity, - width: double.infinity, - ), - Positioned( - bottom: 10.h, - left: 16.w, - right: 16.w, - child: ElevatedButton( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "Something went wrong", + style: context.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + "Please check your internet connection and try again", + style: context.textTheme.titleSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + FilledButton.tonal( onPressed: retryCallback, child: Text(retryText), - ), - ) - ], + ) + ], + ), ), ); } diff --git a/lib/widgets/filled_input_field.dart b/lib/widgets/filled_input_field.dart new file mode 100644 index 0000000..7462c3b --- /dev/null +++ b/lib/widgets/filled_input_field.dart @@ -0,0 +1,72 @@ +part of 'input_field.dart'; + +class FilledInputField extends InputFormField { + FilledInputField({ + super.key, + super.controller, + super.initialValue, + super.focusNode, + InputDecoration decoration = const InputDecoration(), + super.keyboardType, + super.textCapitalization = TextCapitalization.none, + super.textInputAction, + super.style, + super.strutStyle, + super.textDirection, + super.textAlign, + super.textAlignVertical, + super.autofocus, + super.readOnly, + super.toolbarOptions, + super.showCursor, + super.obscuringCharacter = '•', + super.obscureText, + super.autocorrect, + super.smartDashesType, + super.smartQuotesType, + super.enableSuggestions, + super.maxLengthEnforcement, + super.maxLines, + super.minLines, + super.expands, + super.maxLength, + super.onChanged, + super.onTap, + super.onEditingComplete, + super.onFieldSubmitted, + super.inputFormatters, + super.enabled, + super.cursorWidth, + super.cursorHeight, + super.cursorRadius, + super.cursorColor, + super.keyboardAppearance, + super.scrollPadding, + super.enableInteractiveSelection, + super.selectionControls, + super.buildCounter, + super.scrollPhysics, + super.autofillHints, + super.autovalidateMode, + super.scrollController, + super.enableIMEPersonalizedLearning, + super.mouseCursor, + super.restorationId, + super.onSaved, + super.validator, + super.inputType, + required BuildContext context, + }) : super( + decoration: decoration.copyWith( + filled: true, + isDense: true, + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: context.colorScheme.primary), + ), + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(4), + ), + ), + ); +} diff --git a/lib/widgets/input_field.dart b/lib/widgets/input_field.dart index 23ec8eb..dbadce6 100644 --- a/lib/widgets/input_field.dart +++ b/lib/widgets/input_field.dart @@ -1,4 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:varanasi_mobile_app/utils/extensions/extensions.dart'; +import 'package:varanasi_mobile_app/utils/input_validators.dart'; + +part 'filled_input_field.dart'; class InputFormField extends TextFormField { InputFormField({ @@ -53,9 +57,11 @@ class InputFormField extends TextFormField { super.mouseCursor, super.restorationId, super.onSaved, - super.validator, + InputValidator? validator, InputType inputType = InputType.none, - }) : super(obscureText: obscureText ?? inputType == InputType.password); + }) : super( + validator: validator ?? _getInputValidator(inputType), + obscureText: obscureText ?? inputType == InputType.password); InputFormField.custom({ super.key, @@ -110,9 +116,10 @@ class InputFormField extends TextFormField { super.mouseCursor, super.restorationId, super.onSaved, - super.validator, + InputValidator? validator, InputType inputType = InputType.none, }) : super( + validator: validator ?? _getInputValidator(inputType), keyboardType: keyboardType ?? _getTextInputType(inputType), obscureText: obscureText ?? inputType == InputType.password, decoration: size == InputFieldSize.medium @@ -171,9 +178,10 @@ class InputFormField extends TextFormField { super.mouseCursor, super.restorationId, super.onSaved, - super.validator, + InputValidator? validator, InputType inputType = InputType.none, }) : super( + validator: validator ?? _getInputValidator(inputType), keyboardType: keyboardType ?? _getTextInputType(inputType), obscureText: obscureText ?? inputType == InputType.password, decoration: @@ -207,3 +215,11 @@ TextInputType? _getTextInputType(InputType inputType) { return null; } } + +typedef InputValidator = String? Function(String? value); + +InputValidator? _getInputValidator(InputType inputType) => switch (inputType) { + InputType.email => InputValidators.validateEmail, + InputType.password => InputValidators.validatePassword, + _ => null, + }; diff --git a/lib/widgets/music_visualizer.dart b/lib/widgets/music_visualizer.dart index 88eafaa..d6a5d94 100644 --- a/lib/widgets/music_visualizer.dart +++ b/lib/widgets/music_visualizer.dart @@ -52,14 +52,14 @@ class MiniMusicVisualizer extends StatelessWidget { class VisualComponent extends StatefulWidget { const VisualComponent({ - Key? key, + super.key, required this.duration, required this.color, required this.curve, this.width, this.height, required this.animating, - }) : super(key: key); + }); final int duration; final Color color; diff --git a/lib/widgets/page_with_navbar.dart b/lib/widgets/page_with_navbar.dart index cbc353f..552a9b8 100644 --- a/lib/widgets/page_with_navbar.dart +++ b/lib/widgets/page_with_navbar.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; @@ -31,50 +32,53 @@ class PageWithNavbar extends HookWidget { final queue = context .select((MediaPlayerCubit cubit) => cubit.state.queueState.queue); final showPlayer = controller != null && queue.isNotEmpty; - return Scaffold( - body: Stack( - children: [ - Positioned.fill( - bottom: showPlayer ? 56 : 0, - child: child, - ), - if (showPlayer) - SlidingUpPanel( - controller: controller, - renderPanelSheet: false, - backdropEnabled: true, - color: Colors.transparent, - borderRadius: BorderRadius.circular(8), - collapsed: MiniPlayer(panelController: controller), - minHeight: 56, - maxHeight: context.height, - panel: position > 0 - ? Player(panelController: controller) - : const SizedBox.shrink(), - onPanelSlide: (pos) => positionState.value = pos, + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Scaffold( + body: Stack( + children: [ + Positioned.fill( + bottom: showPlayer ? 56 : 0, + child: child, ), - ], - ), - bottomNavigationBar: SizedBox( - height: currentHeight, - child: Transform.translate( - offset: Offset(0.0, currentHeight * position * 0.5), - child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: opacity, - child: OverflowBox( - maxHeight: bottomNavHeight, - child: NavigationBar( - height: bottomNavHeight, - indicatorColor: Colors.transparent, - selectedIndex: child.currentIndex, - onDestinationSelected: (value) { - child.goBranch( - value, - initialLocation: child.currentIndex == value, - ); - }, - destinations: navItems.map(_createDestination).toList(), + if (showPlayer) + SlidingUpPanel( + controller: controller, + renderPanelSheet: false, + backdropEnabled: true, + color: Colors.transparent, + borderRadius: BorderRadius.circular(8), + collapsed: MiniPlayer(panelController: controller), + minHeight: 56, + maxHeight: context.height, + panel: position > 0 + ? Player(panelController: controller) + : const SizedBox.shrink(), + onPanelSlide: (pos) => positionState.value = pos, + ), + ], + ), + bottomNavigationBar: SizedBox( + height: currentHeight, + child: Transform.translate( + offset: Offset(0.0, currentHeight * position * 0.5), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: opacity, + child: OverflowBox( + maxHeight: bottomNavHeight, + child: NavigationBar( + height: bottomNavHeight, + indicatorColor: Colors.transparent, + selectedIndex: child.currentIndex, + onDestinationSelected: (value) { + child.goBranch( + value, + initialLocation: child.currentIndex == value, + ); + }, + destinations: navItems.map(_createDestination).toList(), + ), ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index b78d4e2..61a6f0a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -141,7 +141,6 @@ flutter: - assets/icon/app_icon.jpg - assets/icon/nav/ - assets/icon/ - - shorebird.yaml # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware