diff --git a/lib/application/auth/logout_form/logout_form_bloc.dart b/lib/application/auth/logout_form/logout_form_bloc.dart new file mode 100644 index 0000000..89a9959 --- /dev/null +++ b/lib/application/auth/logout_form/logout_form_bloc.dart @@ -0,0 +1,36 @@ +import 'package:dartz/dartz.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:injectable/injectable.dart'; + +import '../../../domain/auth/auth.dart'; + +part 'logout_form_event.dart'; +part 'logout_form_state.dart'; +part 'logout_form_bloc.freezed.dart'; + +@injectable +class LogoutFormBloc extends Bloc { + final IAuthRepository _repository; + LogoutFormBloc(this._repository) : super(LogoutFormState.initial()) { + on(_onLogoutFormEvent); + } + + Future _onLogoutFormEvent( + LogoutFormEvent event, + Emitter emit, + ) { + return event.map( + submitted: (e) async { + emit(state.copyWith(isSubmitting: true, failureOrAuthOption: none())); + final failureOrAuth = await _repository.logout(); + emit( + state.copyWith( + isSubmitting: false, + failureOrAuthOption: optionOf(failureOrAuth), + ), + ); + }, + ); + } +} diff --git a/lib/application/auth/logout_form/logout_form_bloc.freezed.dart b/lib/application/auth/logout_form/logout_form_bloc.freezed.dart new file mode 100644 index 0000000..2bdad02 --- /dev/null +++ b/lib/application/auth/logout_form/logout_form_bloc.freezed.dart @@ -0,0 +1,335 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'logout_form_bloc.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +/// @nodoc +mixin _$LogoutFormEvent { + @optionalTypeArgs + TResult when({ + required TResult Function() submitted, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? submitted, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? submitted, + required TResult orElse(), + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(_Submitted value) submitted, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_Submitted value)? submitted, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Submitted value)? submitted, + required TResult orElse(), + }) => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $LogoutFormEventCopyWith<$Res> { + factory $LogoutFormEventCopyWith( + LogoutFormEvent value, + $Res Function(LogoutFormEvent) then, + ) = _$LogoutFormEventCopyWithImpl<$Res, LogoutFormEvent>; +} + +/// @nodoc +class _$LogoutFormEventCopyWithImpl<$Res, $Val extends LogoutFormEvent> + implements $LogoutFormEventCopyWith<$Res> { + _$LogoutFormEventCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of LogoutFormEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc +abstract class _$$SubmittedImplCopyWith<$Res> { + factory _$$SubmittedImplCopyWith( + _$SubmittedImpl value, + $Res Function(_$SubmittedImpl) then, + ) = __$$SubmittedImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$SubmittedImplCopyWithImpl<$Res> + extends _$LogoutFormEventCopyWithImpl<$Res, _$SubmittedImpl> + implements _$$SubmittedImplCopyWith<$Res> { + __$$SubmittedImplCopyWithImpl( + _$SubmittedImpl _value, + $Res Function(_$SubmittedImpl) _then, + ) : super(_value, _then); + + /// Create a copy of LogoutFormEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$SubmittedImpl implements _Submitted { + const _$SubmittedImpl(); + + @override + String toString() { + return 'LogoutFormEvent.submitted()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is _$SubmittedImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() submitted, + }) { + return submitted(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? submitted, + }) { + return submitted?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? submitted, + required TResult orElse(), + }) { + if (submitted != null) { + return submitted(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_Submitted value) submitted, + }) { + return submitted(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_Submitted value)? submitted, + }) { + return submitted?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Submitted value)? submitted, + required TResult orElse(), + }) { + if (submitted != null) { + return submitted(this); + } + return orElse(); + } +} + +abstract class _Submitted implements LogoutFormEvent { + const factory _Submitted() = _$SubmittedImpl; +} + +/// @nodoc +mixin _$LogoutFormState { + Option> get failureOrAuthOption => + throw _privateConstructorUsedError; + bool get isSubmitting => throw _privateConstructorUsedError; + + /// Create a copy of LogoutFormState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $LogoutFormStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $LogoutFormStateCopyWith<$Res> { + factory $LogoutFormStateCopyWith( + LogoutFormState value, + $Res Function(LogoutFormState) then, + ) = _$LogoutFormStateCopyWithImpl<$Res, LogoutFormState>; + @useResult + $Res call({ + Option> failureOrAuthOption, + bool isSubmitting, + }); +} + +/// @nodoc +class _$LogoutFormStateCopyWithImpl<$Res, $Val extends LogoutFormState> + implements $LogoutFormStateCopyWith<$Res> { + _$LogoutFormStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of LogoutFormState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? failureOrAuthOption = null, Object? isSubmitting = null}) { + return _then( + _value.copyWith( + failureOrAuthOption: null == failureOrAuthOption + ? _value.failureOrAuthOption + : failureOrAuthOption // ignore: cast_nullable_to_non_nullable + as Option>, + isSubmitting: null == isSubmitting + ? _value.isSubmitting + : isSubmitting // ignore: cast_nullable_to_non_nullable + as bool, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$LogoutFormStateImplCopyWith<$Res> + implements $LogoutFormStateCopyWith<$Res> { + factory _$$LogoutFormStateImplCopyWith( + _$LogoutFormStateImpl value, + $Res Function(_$LogoutFormStateImpl) then, + ) = __$$LogoutFormStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + Option> failureOrAuthOption, + bool isSubmitting, + }); +} + +/// @nodoc +class __$$LogoutFormStateImplCopyWithImpl<$Res> + extends _$LogoutFormStateCopyWithImpl<$Res, _$LogoutFormStateImpl> + implements _$$LogoutFormStateImplCopyWith<$Res> { + __$$LogoutFormStateImplCopyWithImpl( + _$LogoutFormStateImpl _value, + $Res Function(_$LogoutFormStateImpl) _then, + ) : super(_value, _then); + + /// Create a copy of LogoutFormState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? failureOrAuthOption = null, Object? isSubmitting = null}) { + return _then( + _$LogoutFormStateImpl( + failureOrAuthOption: null == failureOrAuthOption + ? _value.failureOrAuthOption + : failureOrAuthOption // ignore: cast_nullable_to_non_nullable + as Option>, + isSubmitting: null == isSubmitting + ? _value.isSubmitting + : isSubmitting // ignore: cast_nullable_to_non_nullable + as bool, + ), + ); + } +} + +/// @nodoc + +class _$LogoutFormStateImpl implements _LogoutFormState { + const _$LogoutFormStateImpl({ + required this.failureOrAuthOption, + this.isSubmitting = false, + }); + + @override + final Option> failureOrAuthOption; + @override + @JsonKey() + final bool isSubmitting; + + @override + String toString() { + return 'LogoutFormState(failureOrAuthOption: $failureOrAuthOption, isSubmitting: $isSubmitting)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$LogoutFormStateImpl && + (identical(other.failureOrAuthOption, failureOrAuthOption) || + other.failureOrAuthOption == failureOrAuthOption) && + (identical(other.isSubmitting, isSubmitting) || + other.isSubmitting == isSubmitting)); + } + + @override + int get hashCode => + Object.hash(runtimeType, failureOrAuthOption, isSubmitting); + + /// Create a copy of LogoutFormState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$LogoutFormStateImplCopyWith<_$LogoutFormStateImpl> get copyWith => + __$$LogoutFormStateImplCopyWithImpl<_$LogoutFormStateImpl>( + this, + _$identity, + ); +} + +abstract class _LogoutFormState implements LogoutFormState { + const factory _LogoutFormState({ + required final Option> failureOrAuthOption, + final bool isSubmitting, + }) = _$LogoutFormStateImpl; + + @override + Option> get failureOrAuthOption; + @override + bool get isSubmitting; + + /// Create a copy of LogoutFormState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$LogoutFormStateImplCopyWith<_$LogoutFormStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/application/auth/logout_form/logout_form_event.dart b/lib/application/auth/logout_form/logout_form_event.dart new file mode 100644 index 0000000..606bf91 --- /dev/null +++ b/lib/application/auth/logout_form/logout_form_event.dart @@ -0,0 +1,6 @@ +part of 'logout_form_bloc.dart'; + +@freezed +class LogoutFormEvent with _$LogoutFormEvent { + const factory LogoutFormEvent.submitted() = _Submitted; +} diff --git a/lib/application/auth/logout_form/logout_form_state.dart b/lib/application/auth/logout_form/logout_form_state.dart new file mode 100644 index 0000000..613c5e1 --- /dev/null +++ b/lib/application/auth/logout_form/logout_form_state.dart @@ -0,0 +1,12 @@ +part of 'logout_form_bloc.dart'; + +@freezed +class LogoutFormState with _$LogoutFormState { + const factory LogoutFormState({ + required Option> failureOrAuthOption, + @Default(false) bool isSubmitting, + }) = _LogoutFormState; + + factory LogoutFormState.initial() => + LogoutFormState(failureOrAuthOption: none(), isSubmitting: false); +} diff --git a/lib/common/api/api_client.dart b/lib/common/api/api_client.dart index 95ab220..31f914a 100644 --- a/lib/common/api/api_client.dart +++ b/lib/common/api/api_client.dart @@ -2,8 +2,10 @@ import 'package:awesome_dio_interceptor/awesome_dio_interceptor.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:injectable/injectable.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../../env.dart'; +import '../constant/local_storage_key.dart'; import 'api_failure.dart'; import 'errors/bad_network_error.dart'; import 'errors/bad_request_error.dart'; @@ -22,11 +24,16 @@ import 'interceptors/unauthorized_interceptor.dart'; class ApiClient { final Dio _dio; final Env _env; + final SharedPreferences _preferences; - ApiClient(this._dio, this._env) { + ApiClient(this._dio, this._env, this._preferences) { _dio.options.baseUrl = _env.baseUrl; _dio.options.validateStatus = (status) => status! < 500; _dio.options.connectTimeout = const Duration(seconds: 20); + _dio.options.headers = { + 'authorization': + 'Bearer ${_preferences.getString(LocalStorageKey.token)}', + }; _dio.interceptors.add(BadNetworkErrorInterceptor()); _dio.interceptors.add(BadRequestErrorInterceptor()); _dio.interceptors.add(InternalServerErrorInterceptor()); diff --git a/lib/common/url/api_path.dart b/lib/common/url/api_path.dart index 78543f2..e3ef4ea 100644 --- a/lib/common/url/api_path.dart +++ b/lib/common/url/api_path.dart @@ -1,3 +1,5 @@ class ApiPath { + // Auth static const String login = '/api/v1/auth/login'; + static const String logout = '/api/v1/auth/logout'; } diff --git a/lib/domain/auth/repositories/i_auth_repository.dart b/lib/domain/auth/repositories/i_auth_repository.dart index 22adf9d..4bb8594 100644 --- a/lib/domain/auth/repositories/i_auth_repository.dart +++ b/lib/domain/auth/repositories/i_auth_repository.dart @@ -7,4 +7,5 @@ abstract class IAuthRepository { }); Future hasToken(); Future> currentUser(); + Future> logout(); } diff --git a/lib/infrastructure/auth/datasources/remote_data_provider.dart b/lib/infrastructure/auth/datasources/remote_data_provider.dart index 41033f3..779f4ba 100644 --- a/lib/infrastructure/auth/datasources/remote_data_provider.dart +++ b/lib/infrastructure/auth/datasources/remote_data_provider.dart @@ -1,5 +1,6 @@ import 'dart:developer'; +import 'package:dartz/dartz.dart'; import 'package:injectable/injectable.dart'; import 'package:data_channel/data_channel.dart'; @@ -41,4 +42,14 @@ class AuthRemoteDataProvider { return DC.error(AuthFailure.serverError(e)); } } + + Future> logout() async { + try { + await _apiClient.post(ApiPath.logout); + return DC.data(unit); + } on ApiFailure catch (e, s) { + log('login', name: _logName, error: e, stackTrace: s); + return DC.error(AuthFailure.serverError(e)); + } + } } diff --git a/lib/infrastructure/auth/repositories/auth_repository.dart b/lib/infrastructure/auth/repositories/auth_repository.dart index 4682b67..c615114 100644 --- a/lib/infrastructure/auth/repositories/auth_repository.dart +++ b/lib/infrastructure/auth/repositories/auth_repository.dart @@ -58,4 +58,20 @@ class AuthRepository implements IAuthRepository { Future hasToken() async { return await _localDataProvider.hasToken(); } + + @override + Future> logout() async { + try { + final result = await _remoteDataProvider.logout(); + if (result.hasError) { + return left(result.error!); + } + + await _localDataProvider.deleteAllAuth(); + return right(unit); + } catch (e, s) { + log('logoutError', name: _logName, error: e, stackTrace: s); + return left(const AuthFailure.unexpectedError()); + } + } } diff --git a/lib/injection.config.dart b/lib/injection.config.dart index c5bfd6f..7cd1c34 100644 --- a/lib/injection.config.dart +++ b/lib/injection.config.dart @@ -12,6 +12,8 @@ import 'package:apskel_owner_flutter/application/auth/auth_bloc.dart' as _i945; import 'package:apskel_owner_flutter/application/auth/login_form/login_form_bloc.dart' as _i775; +import 'package:apskel_owner_flutter/application/auth/logout_form/logout_form_bloc.dart' + as _i574; import 'package:apskel_owner_flutter/application/language/language_bloc.dart' as _i455; import 'package:apskel_owner_flutter/common/api/api_client.dart' as _i115; @@ -76,10 +78,14 @@ extension GetItInjectableX on _i174.GetIt { gh.factory<_i991.AuthLocalDataProvider>( () => _i991.AuthLocalDataProvider(gh<_i460.SharedPreferences>()), ); - gh.lazySingleton<_i115.ApiClient>( - () => _i115.ApiClient(gh<_i361.Dio>(), gh<_i6.Env>()), - ); gh.factory<_i6.Env>(() => _i6.ProdEnv(), registerFor: {_prod}); + gh.lazySingleton<_i115.ApiClient>( + () => _i115.ApiClient( + gh<_i361.Dio>(), + gh<_i6.Env>(), + gh<_i460.SharedPreferences>(), + ), + ); gh.factory<_i17.AuthRemoteDataProvider>( () => _i17.AuthRemoteDataProvider(gh<_i115.ApiClient>()), ); @@ -95,6 +101,9 @@ extension GetItInjectableX on _i174.GetIt { gh.factory<_i945.AuthBloc>( () => _i945.AuthBloc(gh<_i49.IAuthRepository>()), ); + gh.factory<_i574.LogoutFormBloc>( + () => _i574.LogoutFormBloc(gh<_i49.IAuthRepository>()), + ); return this; } } diff --git a/lib/presentation/pages/profile/profile_page.dart b/lib/presentation/pages/profile/profile_page.dart index 0949d45..3422a74 100644 --- a/lib/presentation/pages/profile/profile_page.dart +++ b/lib/presentation/pages/profile/profile_page.dart @@ -1,10 +1,15 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:line_icons/line_icons.dart'; +import '../../../application/auth/logout_form/logout_form_bloc.dart'; import '../../../common/theme/theme.dart'; +import '../../../injection.dart'; import '../../components/button/button.dart'; import '../../components/spacer/spacer.dart'; +import '../../components/toast/flushbar.dart'; +import '../../router/app_router.gr.dart'; import 'widgets/account_info.dart'; import 'widgets/app_setting.dart'; import 'widgets/business_setting.dart'; @@ -13,88 +18,98 @@ import 'widgets/header.dart'; import 'widgets/support.dart'; @RoutePage() -class ProfilePage extends StatefulWidget { +class ProfilePage extends StatelessWidget implements AutoRouteWrapper { const ProfilePage({super.key}); - @override - State createState() => _ProfilePageState(); -} - -class _ProfilePageState extends State { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColor.background, - body: CustomScrollView( - slivers: [ - SliverAppBar( - backgroundColor: AppColor.primary, - elevation: 0, - pinned: true, - expandedHeight: 264.0, - flexibleSpace: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - // Calculate the collapse ratio - final double top = constraints.biggest.height; - final double collapsedHeight = - MediaQuery.of(context).padding.top + kToolbarHeight; - final double expandedHeight = 264.0; - final double shrinkRatio = - ((expandedHeight - top) / - (expandedHeight - collapsedHeight)) - .clamp(0.0, 1.0); + return BlocListener( + listener: (context, state) { + state.failureOrAuthOption.fold( + () => null, + (either) => either.fold( + (f) => AppFlushbar.showAuthFailureToast(context, f), + (_) => context.router.replace(const LoginRoute()), + ), + ); + }, + child: Scaffold( + backgroundColor: AppColor.background, + body: CustomScrollView( + slivers: [ + SliverAppBar( + backgroundColor: AppColor.primary, + elevation: 0, + pinned: true, + expandedHeight: 264.0, + flexibleSpace: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + // Calculate the collapse ratio + final double top = constraints.biggest.height; + final double collapsedHeight = + MediaQuery.of(context).padding.top + kToolbarHeight; + final double expandedHeight = 264.0; + final double shrinkRatio = + ((expandedHeight - top) / + (expandedHeight - collapsedHeight)) + .clamp(0.0, 1.0); - return FlexibleSpaceBar( - background: ProfileHeader(), - titlePadding: const EdgeInsets.only( - left: 20, - right: 12, - bottom: 16, - ), - title: Opacity( - opacity: shrinkRatio, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Profile', - style: AppStyle.xl.copyWith( - fontWeight: FontWeight.w700, - fontSize: 18, - letterSpacing: -0.5, - color: AppColor.white, - ), - ), - ActionIconButton( - onTap: () {}, - icon: LineIcons.userEdit, - ), - ], + return FlexibleSpaceBar( + background: ProfileHeader(), + titlePadding: const EdgeInsets.only( + left: 20, + right: 12, + bottom: 16, ), - ), - ); - }, + title: Opacity( + opacity: shrinkRatio, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Profile', + style: AppStyle.xl.copyWith( + fontWeight: FontWeight.w700, + fontSize: 18, + letterSpacing: -0.5, + color: AppColor.white, + ), + ), + ActionIconButton( + onTap: () {}, + icon: LineIcons.userEdit, + ), + ], + ), + ), + ); + }, + ), ), - ), - SliverToBoxAdapter( - child: Column( - children: [ - const SpaceHeight(20), - ProfileAccountInfo(), - const SpaceHeight(12), - ProfileBusinessSetting(), - const SpaceHeight(12), - ProfileAppSetting(), - const SpaceHeight(12), - ProfileSupport(), - const SpaceHeight(12), - ProfileDangerZone(), - const SpaceHeight(30), - ], + SliverToBoxAdapter( + child: Column( + children: [ + const SpaceHeight(20), + ProfileAccountInfo(), + const SpaceHeight(12), + ProfileBusinessSetting(), + const SpaceHeight(12), + ProfileAppSetting(), + const SpaceHeight(12), + ProfileSupport(), + const SpaceHeight(12), + ProfileDangerZone(), + const SpaceHeight(30), + ], + ), ), - ), - ], + ], + ), ), ); } + + @override + Widget wrappedRoute(BuildContext context) => + BlocProvider(create: (_) => getIt(), child: this); } diff --git a/lib/presentation/pages/profile/widgets/danger_zone.dart b/lib/presentation/pages/profile/widgets/danger_zone.dart index 376247f..9d8c873 100644 --- a/lib/presentation/pages/profile/widgets/danger_zone.dart +++ b/lib/presentation/pages/profile/widgets/danger_zone.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../application/auth/logout_form/logout_form_bloc.dart'; import '../../../../common/theme/theme.dart'; import '../../../components/spacer/spacer.dart'; import 'profile_tile.dart'; @@ -49,7 +51,9 @@ class ProfileDangerZone extends StatelessWidget { subtitle: 'Sign out from your account', iconColor: AppColor.error, textColor: AppColor.error, - onTap: () {}, + onTap: () { + context.read().add(LogoutFormEvent.submitted()); + }, ), ], ),