feat: Refactor code structure and optimize performance across multiple modules
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/app.dart';
|
||||
import 'package:krow/features/auth/domain/bloc/auth_bloc.dart';
|
||||
import 'package:krow/core/application/routing/routes.gr.dart';
|
||||
|
||||
@RoutePage()
|
||||
class AuthFlowScreen extends StatefulWidget implements AutoRouteWrapper {
|
||||
const AuthFlowScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AuthFlowScreen> createState() => _AuthFlowScreenState();
|
||||
|
||||
@override
|
||||
Widget wrappedRoute(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => AuthBloc(),
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AuthFlowScreenState extends State<AuthFlowScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final context = this.context;
|
||||
AppLinks().getInitialLink().then(
|
||||
(initialLink) {
|
||||
if (initialLink == null || !context.mounted) return;
|
||||
|
||||
context
|
||||
.read<AuthBloc>()
|
||||
.add(SignInWithInitialLink(initialLink: initialLink));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state.autentificated) {
|
||||
if (state.authType == AuthType.login) {
|
||||
appRouter.replace(const SplashRoute());
|
||||
} else {
|
||||
appRouter.replace(const SignupFlowRoute());
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const AutoRouter(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/core/application/common/validators/phone_validator.dart';
|
||||
import 'package:krow/core/application/di/injectable.dart';
|
||||
import 'package:krow/core/sevices/auth_state_service/auth_service.dart';
|
||||
|
||||
part 'auth_event.dart';
|
||||
|
||||
part 'auth_state.dart';
|
||||
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||
String? phone;
|
||||
int? resendToken;
|
||||
String? verificationId;
|
||||
|
||||
AuthBloc() : super(AuthState()) {
|
||||
on<AuthTypeChangedEvent>(_onTypeChanged);
|
||||
on<AuthSendSmsEvent>(_onSendSms);
|
||||
on<AuthSendSmsToPhoneEvent>(_onSendSmsToCurrentPhone);
|
||||
on<AuthPhoneChangedEvent>(_onPhoneChanged);
|
||||
on<PhoneVerificationCompletedEvent>(_onPhoneVerificationCompleted);
|
||||
on<PhoneVerificationFailedEvent>(_onPhoneVerificationFailed);
|
||||
on<CodeSentEvent>(_onCodeSent);
|
||||
on<CodeAutoRetrievalTimeoutEvent>(_onCodeAutoRetrievalTimeout);
|
||||
on<AuthEventResendUpdateTimer>(_onUpdateTimer);
|
||||
on<AuthEventConfirmCode>(_onConfirmCode);
|
||||
on<SignInWithInitialLink>(_onSignInWithInitialLink);
|
||||
}
|
||||
|
||||
FutureOr<void> _onTypeChanged(
|
||||
AuthTypeChangedEvent event,
|
||||
Emitter<AuthState> emit,
|
||||
) {
|
||||
emit(state.copyWith(authType: event.authType));
|
||||
}
|
||||
|
||||
FutureOr<void> _onPhoneChanged(
|
||||
AuthPhoneChangedEvent event,
|
||||
Emitter<AuthState> emit,
|
||||
) {
|
||||
emit(state.copyWith(phoneError: null));
|
||||
}
|
||||
|
||||
Future<void> _handleSendingSmsToPhoneNumber(
|
||||
String? phone,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
if (state.resendTimeout > 0) return;
|
||||
|
||||
if (phone != null) {
|
||||
var phoneValidationResult = PhoneValidator.validate(phone);
|
||||
if (phoneValidationResult != null) {
|
||||
return emit(state.copyWith(phoneError: phoneValidationResult));
|
||||
}
|
||||
|
||||
resendToken = null;
|
||||
verificationId = null;
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
emit(state.copyWith(isLoading: true));
|
||||
|
||||
try {
|
||||
await _auth.verifyPhoneNumber(
|
||||
forceResendingToken: resendToken,
|
||||
phoneNumber: phone,
|
||||
verificationCompleted: (PhoneAuthCredential credential) {
|
||||
add(PhoneVerificationCompletedEvent(credential));
|
||||
},
|
||||
verificationFailed: (FirebaseAuthException e) {
|
||||
add(PhoneVerificationFailedEvent(e.message));
|
||||
},
|
||||
codeSent: (String verificationId, int? resendToken) {
|
||||
this.verificationId = verificationId;
|
||||
this.resendToken = resendToken;
|
||||
add(CodeSentEvent(verificationId, resendToken));
|
||||
},
|
||||
codeAutoRetrievalTimeout: (String verificationId) {
|
||||
if (isClosed) return;
|
||||
add(CodeAutoRetrievalTimeoutEvent(verificationId));
|
||||
},
|
||||
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: false,
|
||||
phoneError: 'Invalid phone number'.tr(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSendSms(
|
||||
AuthSendSmsEvent event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
return _handleSendingSmsToPhoneNumber(event.phone, emit);
|
||||
}
|
||||
|
||||
FutureOr<void> _onSendSmsToCurrentPhone(
|
||||
AuthSendSmsToPhoneEvent event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
return _handleSendingSmsToPhoneNumber(
|
||||
phone = event.userPhone,
|
||||
emit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onConfirmCode(
|
||||
AuthEventConfirmCode event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
|
||||
PhoneAuthCredential credential = PhoneAuthProvider.credential(
|
||||
verificationId: verificationId ?? '',
|
||||
smsCode: event.smsCode,
|
||||
);
|
||||
|
||||
add(PhoneVerificationCompletedEvent(credential));
|
||||
}
|
||||
|
||||
FutureOr<void> _onUpdateTimer(
|
||||
AuthEventResendUpdateTimer event,
|
||||
Emitter<AuthState> emit,
|
||||
) {
|
||||
emit(state.copyWith(
|
||||
resendTimeout: event.seconds, codeError: state.codeError));
|
||||
}
|
||||
|
||||
FutureOr<void> _onPhoneVerificationCompleted(
|
||||
PhoneVerificationCompletedEvent event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _auth.signInWithCredential(event.credential);
|
||||
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
autentificated: true,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
codeError: 'Invalid code'.tr(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
FutureOr<void> _onPhoneVerificationFailed(
|
||||
PhoneVerificationFailedEvent event,
|
||||
Emitter<AuthState> emit,
|
||||
) {
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
phoneError: event.error,
|
||||
));
|
||||
}
|
||||
|
||||
FutureOr<void> _onCodeSent(
|
||||
CodeSentEvent event,
|
||||
Emitter<AuthState> emit,
|
||||
) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
needNavigateToCodeVerification: true,
|
||||
resendTimeout: 30,
|
||||
),
|
||||
);
|
||||
_startRetryTimer();
|
||||
}
|
||||
|
||||
FutureOr<void> _onCodeAutoRetrievalTimeout(
|
||||
CodeAutoRetrievalTimeoutEvent event,
|
||||
Emitter<AuthState> emit,
|
||||
) {}
|
||||
|
||||
void _startRetryTimer() {
|
||||
var current = 30;
|
||||
Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (current <= 0) {
|
||||
timer.cancel();
|
||||
} else {
|
||||
current--;
|
||||
if (isClosed) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
add(AuthEventResendUpdateTimer(current));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
FutureOr<void> _onSignInWithInitialLink(
|
||||
SignInWithInitialLink event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
try {
|
||||
await getIt<AuthService>().signInWithEmailLink(link: event.initialLink);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: false,
|
||||
autentificated: true,
|
||||
),
|
||||
);
|
||||
} catch (except) {
|
||||
log('Failed to authenticate with Initial Link', error: except);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
part of 'auth_bloc.dart';
|
||||
|
||||
@immutable
|
||||
sealed class AuthEvent {
|
||||
const AuthEvent();
|
||||
}
|
||||
|
||||
class AuthTypeChangedEvent extends AuthEvent {
|
||||
final AuthType authType;
|
||||
|
||||
const AuthTypeChangedEvent(this.authType);
|
||||
}
|
||||
|
||||
class AuthPhoneChangedEvent extends AuthEvent {
|
||||
const AuthPhoneChangedEvent();
|
||||
}
|
||||
|
||||
class AuthSendSmsEvent extends AuthEvent {
|
||||
final String? phone;
|
||||
|
||||
const AuthSendSmsEvent(this.phone);
|
||||
}
|
||||
|
||||
class AuthSendSmsToPhoneEvent extends AuthEvent {
|
||||
const AuthSendSmsToPhoneEvent(this.userPhone);
|
||||
|
||||
final String userPhone;
|
||||
}
|
||||
|
||||
class PhoneVerificationCompletedEvent extends AuthEvent {
|
||||
final PhoneAuthCredential credential;
|
||||
|
||||
const PhoneVerificationCompletedEvent(this.credential);
|
||||
}
|
||||
|
||||
class PhoneVerificationFailedEvent extends AuthEvent {
|
||||
final String? error;
|
||||
|
||||
const PhoneVerificationFailedEvent(this.error);
|
||||
}
|
||||
|
||||
class CodeSentEvent extends AuthEvent {
|
||||
final String verificationId;
|
||||
final int? resendToken;
|
||||
|
||||
const CodeSentEvent(this.verificationId, this.resendToken);
|
||||
}
|
||||
|
||||
class CodeAutoRetrievalTimeoutEvent extends AuthEvent {
|
||||
final String verificationId;
|
||||
|
||||
const CodeAutoRetrievalTimeoutEvent(this.verificationId);
|
||||
}
|
||||
|
||||
class AuthEventResendUpdateTimer extends AuthEvent {
|
||||
final int seconds;
|
||||
|
||||
const AuthEventResendUpdateTimer(this.seconds);
|
||||
}
|
||||
|
||||
class AuthEventConfirmCode extends AuthEvent {
|
||||
final String smsCode;
|
||||
|
||||
const AuthEventConfirmCode(this.smsCode);
|
||||
}
|
||||
|
||||
class SignInWithInitialLink extends AuthEvent {
|
||||
const SignInWithInitialLink({required this.initialLink});
|
||||
|
||||
final Uri initialLink;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
part of 'auth_bloc.dart';
|
||||
|
||||
enum AuthType { login, register }
|
||||
|
||||
class AuthState {
|
||||
final AuthType? authType;
|
||||
final String? phoneError;
|
||||
String? codeError;
|
||||
final bool needClearPhone;
|
||||
final bool needNavigateToCodeVerification;
|
||||
final bool isLoading;
|
||||
final bool autentificated;
|
||||
final int resendTimeout;
|
||||
|
||||
AuthState({
|
||||
this.phoneError,
|
||||
this.codeError,
|
||||
this.needClearPhone = false,
|
||||
this.needNavigateToCodeVerification = false,
|
||||
this.isLoading = false,
|
||||
this.authType,
|
||||
this.autentificated = false,
|
||||
this.resendTimeout = 0,
|
||||
});
|
||||
|
||||
AuthState copyWith({
|
||||
AuthType? authType,
|
||||
String? phoneError,
|
||||
String? codeError,
|
||||
bool? needClearPhone,
|
||||
bool? needNavigateToCodeVerification,
|
||||
bool? isLoading,
|
||||
bool? autentificated,
|
||||
int? resendTimeout,
|
||||
}) {
|
||||
return AuthState(
|
||||
authType: authType ?? this.authType,
|
||||
phoneError: phoneError,
|
||||
codeError: codeError,
|
||||
needClearPhone: needClearPhone ?? false,
|
||||
needNavigateToCodeVerification: needNavigateToCodeVerification ?? false,
|
||||
isLoading: isLoading ?? false,
|
||||
autentificated: autentificated ?? false,
|
||||
resendTimeout: resendTimeout ?? this.resendTimeout,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/features/auth/domain/bloc/auth_bloc.dart';
|
||||
|
||||
@RoutePage()
|
||||
class PhoneReLoginFlowScreen extends StatelessWidget
|
||||
implements AutoRouteWrapper {
|
||||
const PhoneReLoginFlowScreen({super.key, required this.userPhone});
|
||||
|
||||
final String userPhone;
|
||||
|
||||
@override
|
||||
Widget wrappedRoute(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => AuthBloc()..add(AuthSendSmsToPhoneEvent(userPhone)),
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.autentificated != current.autentificated,
|
||||
listener: (context, state) {
|
||||
if (state.autentificated) context.maybePop(true);
|
||||
},
|
||||
child: const AutoRouter(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/features/auth/domain/bloc/auth_bloc.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
|
||||
import 'package:krow/core/presentation/widgets/scroll_layout_helper.dart';
|
||||
import 'package:pinput/pinput.dart';
|
||||
|
||||
@RoutePage()
|
||||
class CodeVerificationScreen extends StatefulWidget {
|
||||
const CodeVerificationScreen({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CodeVerificationScreen> createState() => _CodeVerificationScreenState();
|
||||
}
|
||||
|
||||
class _CodeVerificationScreenState extends State<CodeVerificationScreen> {
|
||||
late final TapGestureRecognizer resendRecognizer;
|
||||
String code = '';
|
||||
|
||||
String _title(AuthState state) {
|
||||
switch (state.authType) {
|
||||
case AuthType.login:
|
||||
return 'Welcome Back!'.tr();
|
||||
default:
|
||||
return 'Enter Verification Code'.tr();
|
||||
}
|
||||
}
|
||||
|
||||
String get _subTitle {
|
||||
return 'Enter the 6-digit'.tr();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
resendRecognizer = TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
BlocProvider.of<AuthBloc>(context).add(const AuthSendSmsEvent(null));
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: KwAppBar(
|
||||
titleText: 'Phone Verification'.tr(),
|
||||
showNotification: false,
|
||||
),
|
||||
body: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
return ScrollLayoutHelper(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
upperWidget: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Gap(20),
|
||||
Text(
|
||||
_title(state),
|
||||
style: AppTextStyles.headingH1,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
_subTitle,
|
||||
style: AppTextStyles.bodyMediumReg
|
||||
.copyWith(color: AppColors.blackGray),
|
||||
),
|
||||
const Gap(24),
|
||||
_buildCodeInput(context, state),
|
||||
const Gap(12),
|
||||
_buildCodeControl(context, state),
|
||||
],
|
||||
),
|
||||
lowerWidget: _buildControlNavButton(context, state.authType),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControlNavButton(BuildContext context, authType) {
|
||||
return Column(
|
||||
children: [
|
||||
KwButton.primary(
|
||||
label: 'Enter and Continue'.tr(),
|
||||
disabled: code.length != 6,
|
||||
onPressed: () {
|
||||
BlocProvider.of<AuthBloc>(context).add(AuthEventConfirmCode(code));
|
||||
},
|
||||
),
|
||||
const Gap(36),
|
||||
if (authType == AuthType.login) ...[
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
text: '${'New here?'.tr()} ',
|
||||
style: AppTextStyles.bodyMediumReg
|
||||
.copyWith(color: AppColors.blackGray),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'Create an account'.tr(),
|
||||
style: AppTextStyles.bodyMediumSmb,
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
BlocProvider.of<AuthBloc>(context)
|
||||
.add(const AuthTypeChangedEvent(AuthType.register));
|
||||
// context.maybePop();
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(20),
|
||||
]
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCodeInput(BuildContext context, AuthState state) {
|
||||
final defaultPinTheme = PinTheme(
|
||||
width: 43,
|
||||
height: 52,
|
||||
textStyle: AppTextStyles.headingH1,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.grayStroke),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
);
|
||||
|
||||
var focusedPinTheme = defaultPinTheme.copyWith(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.blackBlack),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
);
|
||||
|
||||
var errorPinTheme = defaultPinTheme.copyWith(
|
||||
textStyle: AppTextStyles.headingH1.copyWith(color: AppColors.statusError),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.statusError),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
);
|
||||
|
||||
return Pinput(
|
||||
length: 6,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
onCompleted: (pin) =>
|
||||
BlocProvider.of<AuthBloc>(context).add(AuthEventConfirmCode(code)),
|
||||
onChanged: (pin) {
|
||||
setState(() {
|
||||
state.codeError = null;
|
||||
code = pin;
|
||||
});
|
||||
},
|
||||
defaultPinTheme: defaultPinTheme,
|
||||
focusedPinTheme: focusedPinTheme,
|
||||
errorPinTheme: errorPinTheme,
|
||||
forceErrorState: state.codeError != null,
|
||||
);
|
||||
}
|
||||
|
||||
_buildCodeControl(BuildContext context, AuthState state) {
|
||||
return Row(
|
||||
children: [
|
||||
if (state.codeError != null) ...[
|
||||
Expanded(
|
||||
child: Text(
|
||||
state.codeError!,
|
||||
style: AppTextStyles.bodyTinyMed
|
||||
.copyWith(color: AppColors.statusError),
|
||||
),
|
||||
),
|
||||
const Gap(24),
|
||||
],
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
if (state.codeError == null)
|
||||
TextSpan(
|
||||
text: '${'Didn`t get the code?'.tr()} ',
|
||||
style: AppTextStyles.bodyTinyMed,
|
||||
),
|
||||
TextSpan(
|
||||
text: 'Resend Code'.tr(),
|
||||
recognizer: resendRecognizer,
|
||||
style: AppTextStyles.bodyTinyMed.copyWith(
|
||||
decoration: TextDecoration.underline,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (state.resendTimeout > 0)
|
||||
TextSpan(
|
||||
text: ' ${'in'.tr()} ${state.resendTimeout} s',
|
||||
recognizer: resendRecognizer,
|
||||
style: AppTextStyles.bodyTinyMed.copyWith(
|
||||
decoration: TextDecoration.underline,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/application/routing/routes.gr.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/core/presentation/widgets/scroll_layout_helper.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_phone_input.dart';
|
||||
import 'package:krow/features/auth/domain/bloc/auth_bloc.dart';
|
||||
import 'package:modal_progress_hud_nsn/modal_progress_hud_nsn.dart';
|
||||
|
||||
@RoutePage()
|
||||
class PhoneVerificationScreen extends StatefulWidget {
|
||||
const PhoneVerificationScreen({super.key, required this.type});
|
||||
|
||||
final AuthType type;
|
||||
|
||||
@override
|
||||
State<PhoneVerificationScreen> createState() =>
|
||||
_PhoneVerificationScreenState();
|
||||
}
|
||||
|
||||
class _PhoneVerificationScreenState extends State<PhoneVerificationScreen> {
|
||||
late TextEditingController _phoneController;
|
||||
|
||||
String? _appBarTitle(AuthState state) {
|
||||
switch (state.authType) {
|
||||
case AuthType.login:
|
||||
return null;
|
||||
case AuthType.register:
|
||||
return 'Phone Verification'.tr();
|
||||
case null:
|
||||
return ' ';
|
||||
}
|
||||
}
|
||||
|
||||
String _title(AuthState state) {
|
||||
switch (state.authType) {
|
||||
case AuthType.login:
|
||||
return 'Welcome Back!'.tr();
|
||||
case AuthType.register:
|
||||
return "Let's Get Started!".tr();
|
||||
case null:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
String _subTitle(AuthState state) {
|
||||
switch (state.authType) {
|
||||
case AuthType.login:
|
||||
return 'Log in to find work opportunities that match your skills'.tr();
|
||||
case AuthType.register:
|
||||
return 'Verify your phone number to activate your account'.tr();
|
||||
case null:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_phoneController = TextEditingController(text: '+1');
|
||||
_phoneController.addListener(() {
|
||||
context.read<AuthBloc>().add(const AuthPhoneChangedEvent());
|
||||
});
|
||||
|
||||
_debug();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<AuthBloc, AuthState>(listener: (context, state) {
|
||||
if (state.needClearPhone) {
|
||||
_phoneController.clear();
|
||||
}
|
||||
if (state.needNavigateToCodeVerification) {
|
||||
context.router.push(const CodeVerificationRoute());
|
||||
}
|
||||
}, builder: (context, state) {
|
||||
return ModalProgressHUD(
|
||||
inAsyncCall: state.isLoading,
|
||||
child: Scaffold(
|
||||
appBar: KwAppBar(
|
||||
titleText: _appBarTitle(state),
|
||||
showNotification: false,
|
||||
),
|
||||
body: ScrollLayoutHelper(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
upperWidget: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Gap(20),
|
||||
Text(
|
||||
_title(state),
|
||||
style: AppTextStyles.headingH1,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
_subTitle(state),
|
||||
style: AppTextStyles.bodyMediumReg
|
||||
.copyWith(color: AppColors.blackGray),
|
||||
),
|
||||
const Gap(24),
|
||||
KwPhoneInput(
|
||||
title: 'Phone Number'.tr(),
|
||||
controller: _phoneController,
|
||||
error: state.phoneError,
|
||||
),
|
||||
const Gap(24),
|
||||
],
|
||||
),
|
||||
lowerWidget: _buildControNavButton(context, state),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildControNavButton(context, AuthState state) {
|
||||
return Column(
|
||||
children: [
|
||||
KwButton.primary(
|
||||
label:
|
||||
'${'Continue'.tr()} ${state.resendTimeout > 0 ? '${'in'.tr()} ${state.resendTimeout}' : ''}',
|
||||
disabled: _phoneController.text.isEmpty || state.resendTimeout > 0,
|
||||
onPressed: () => BlocProvider.of<AuthBloc>(context).add(
|
||||
AuthSendSmsEvent(_phoneController.text),
|
||||
),
|
||||
),
|
||||
const Gap(36),
|
||||
if (state.authType == AuthType.login) ...[
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
text: '${'New here?'.tr()} ',
|
||||
style: AppTextStyles.bodyMediumReg
|
||||
.copyWith(color: AppColors.blackGray),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'Create an account'.tr(),
|
||||
style: AppTextStyles.bodyMediumSmb,
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
_phoneController.text = '+1';
|
||||
BlocProvider.of<AuthBloc>(context).add(
|
||||
const AuthTypeChangedEvent(AuthType.register),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(20),
|
||||
]
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _debug() {
|
||||
if (!kDebugMode) return;
|
||||
_phoneController.text = const String.fromEnvironment(
|
||||
'DEBUG_PHONE',
|
||||
defaultValue: '+1',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
|
||||
import 'package:krow/features/auth/domain/bloc/auth_bloc.dart';
|
||||
import 'package:krow/core/presentation/gen/assets.gen.dart';
|
||||
import 'package:krow/core/application/routing/routes.gr.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/features/auth/presentation/screens/welcome/widgets/showcase_carousel_widget.dart';
|
||||
|
||||
@RoutePage()
|
||||
class WelcomeScreen extends StatefulWidget {
|
||||
const WelcomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<WelcomeScreen> createState() => _WelcomeScreenState();
|
||||
}
|
||||
|
||||
class _WelcomeScreenState extends State<WelcomeScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.bgColorDark,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: SvgPicture.asset(
|
||||
Assets.images.bg.path,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Gap(20),
|
||||
Center(
|
||||
child: Assets.images.logo.svg(),
|
||||
),
|
||||
const Gap(20),
|
||||
const Expanded(
|
||||
child: ShowcaseCarouselWidget(),
|
||||
),
|
||||
const Gap(36),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'work_that_fits'.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTextStyles.headingH1.copyWith(
|
||||
color: AppColors.grayWhite,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'join_the_community'.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTextStyles.bodyMediumReg.copyWith(
|
||||
color: AppColors.blackDarkBgBody,
|
||||
),
|
||||
),
|
||||
const Gap(36),
|
||||
KwButton.accent(
|
||||
label: 'Sign Up'.tr(),
|
||||
onPressed: () {
|
||||
context
|
||||
.read<AuthBloc>()
|
||||
.add(const AuthTypeChangedEvent(AuthType.register));
|
||||
context.pushRoute(
|
||||
PhoneVerificationRoute(type: AuthType.register));
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
KwButton.outlinedAccent(
|
||||
label: 'Log In'.tr(),
|
||||
onPressed: () {
|
||||
context
|
||||
.read<AuthBloc>()
|
||||
.add(const AuthTypeChangedEvent(AuthType.login));
|
||||
context.pushRoute(
|
||||
PhoneVerificationRoute(type: AuthType.login));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(34),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:carousel_slider/carousel_slider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/presentation/gen/assets.gen.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
|
||||
class ShowcaseCarouselWidget extends StatefulWidget {
|
||||
const ShowcaseCarouselWidget({super.key});
|
||||
|
||||
@override
|
||||
State<ShowcaseCarouselWidget> createState() => _ShowcaseCarouselWidgetState();
|
||||
}
|
||||
|
||||
class _ShowcaseCarouselWidgetState extends State<ShowcaseCarouselWidget> {
|
||||
int _currentIndex = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CarouselSlider(
|
||||
options: CarouselOptions(
|
||||
aspectRatio: 10 / 9,
|
||||
viewportFraction: 1,
|
||||
autoPlay: true,
|
||||
autoPlayAnimationDuration: Durations.medium4,
|
||||
autoPlayInterval: const Duration(seconds: 2),
|
||||
onPageChanged: (index, _) {
|
||||
setState(() => _currentIndex = index);
|
||||
},
|
||||
),
|
||||
items: [
|
||||
for (final image in Assets.images.slider.values)
|
||||
image.image(
|
||||
fit: BoxFit.fitWidth,
|
||||
width: double.maxFinite,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
3,
|
||||
(index) {
|
||||
return AnimatedContainer(
|
||||
duration: Durations.short4,
|
||||
width: _currentIndex == index ? 30 : 6.0,
|
||||
height: 6.0,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 5.0,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(20),
|
||||
),
|
||||
color: AppColors.grayWhite,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:krow/core/application/clients/api/api_client.dart';
|
||||
import 'package:krow/features/check_list/data/gql.dart';
|
||||
|
||||
@injectable
|
||||
class CheckListApiProvider {
|
||||
CheckListApiProvider(this._apiClient);
|
||||
|
||||
static const _verificationListKey = 'verification_check_list';
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Stream<Map<String, dynamic>> fetchCheckListWithCache() async* {
|
||||
await for (var result
|
||||
in _apiClient.queryWithCache(schema: getCheckListQuery)) {
|
||||
if (result == null || result.data == null) continue;
|
||||
|
||||
if (result.hasException) {
|
||||
throw Exception(result.exception.toString());
|
||||
}
|
||||
|
||||
if (result.data?[_verificationListKey] == null) continue;
|
||||
yield result.data?[_verificationListKey] ?? {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fetchCheckList() async {
|
||||
final result = await _apiClient.query(schema: getCheckListQuery);
|
||||
|
||||
if (result.hasException) {
|
||||
throw Exception(result.exception.toString());
|
||||
}
|
||||
|
||||
return result.data?[_verificationListKey] ?? {};
|
||||
}
|
||||
|
||||
Future<void> submitVerification() async {
|
||||
var result = await _apiClient.mutate(schema: submitVerificationMutation);
|
||||
|
||||
if (result.hasException) {
|
||||
throw Exception(result.exception.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:krow/features/check_list/data/check_list_api_provider.dart';
|
||||
import 'package:krow/features/check_list/domain/check_list_repository.dart';
|
||||
|
||||
@Injectable(as: CheckListRepository)
|
||||
class CheckListRepositoryImpl implements CheckListRepository {
|
||||
final CheckListApiProvider remoteDataSource;
|
||||
|
||||
CheckListRepositoryImpl({required this.remoteDataSource});
|
||||
|
||||
@override
|
||||
Stream<Map<String, dynamic>> getCheckList() {
|
||||
return remoteDataSource.fetchCheckListWithCache();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> getCheckListUpdate() {
|
||||
return remoteDataSource.fetchCheckList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> submitVerification() async {
|
||||
return remoteDataSource.submitVerification();
|
||||
}
|
||||
}
|
||||
23
mobile-apps/staff-app/lib/features/check_list/data/gql.dart
Normal file
23
mobile-apps/staff-app/lib/features/check_list/data/gql.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
const String getCheckListQuery = '''
|
||||
query checkList{
|
||||
verification_check_list {
|
||||
personal_info
|
||||
emergency_contacts
|
||||
roles
|
||||
equipments
|
||||
uniforms
|
||||
working_areas
|
||||
bank_account
|
||||
certificates
|
||||
schedule
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
const String submitVerificationMutation = '''
|
||||
mutation submitVerification{
|
||||
submit_staff_profile_for_verification {
|
||||
status
|
||||
}
|
||||
}
|
||||
''';
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/core/application/di/injectable.dart';
|
||||
import 'package:krow/core/application/routing/routes.gr.dart';
|
||||
import 'package:krow/features/check_list/domain/bloc/check_list_event.dart';
|
||||
import 'package:krow/features/check_list/domain/bloc/check_list_state.dart';
|
||||
import 'package:krow/features/check_list/domain/check_list_repository.dart';
|
||||
import 'package:krow/features/profile/role_kit/domain/staff_role_kit_repository_impl.dart';
|
||||
|
||||
class CheckListBloc extends Bloc<CheckListEvent, CheckListState> {
|
||||
CheckListBloc() : super(const CheckListState()) {
|
||||
on<CheckListEventFetch>(_onFetch);
|
||||
on<CheckForListUpdateEvent>(_onCheckForUpdate);
|
||||
on<CheckListEventSubmit>(_onSubmit);
|
||||
on<CheckListEventAgree>(_onAgree);
|
||||
}
|
||||
|
||||
void _onFetch(CheckListEventFetch event, Emitter<CheckListState> emit) async {
|
||||
if (state.checkListItems.isNotEmpty) return;
|
||||
|
||||
await for (var checkList in getIt<CheckListRepository>().getCheckList()) {
|
||||
emit(state.copyWith(checkListItems: _parseCheckListData(checkList)));
|
||||
}
|
||||
}
|
||||
|
||||
void _onCheckForUpdate(
|
||||
CheckForListUpdateEvent event,
|
||||
Emitter<CheckListState> emit,
|
||||
) async {
|
||||
if (event.editedItem.error == null) return;
|
||||
|
||||
final result = await getIt<CheckListRepository>().getCheckListUpdate();
|
||||
emit(
|
||||
state.copyWith(
|
||||
checkListItems: _parseCheckListData(result),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onSubmit(
|
||||
CheckListEventSubmit event,
|
||||
Emitter<CheckListState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(loading: true));
|
||||
try {
|
||||
await getIt<CheckListRepository>().submitVerification();
|
||||
} finally {
|
||||
emit(state.copyWith(loading: false));
|
||||
}
|
||||
emit(state.copyWith(isSubmitted: true));
|
||||
}
|
||||
|
||||
void _onAgree(CheckListEventAgree event, Emitter<CheckListState> emit) {
|
||||
emit(state.copyWith(isAgree: !state.isAgree));
|
||||
}
|
||||
|
||||
List<CheckListItemState> _parseCheckListData(Map<String, dynamic> checkList) {
|
||||
return [
|
||||
CheckListItemState(
|
||||
title: 'Personal Information'.tr(),
|
||||
error: checkList['personal_info'],
|
||||
route: PersonalInfoRoute(isInEditMode: true)),
|
||||
CheckListItemState(
|
||||
title: 'Emergency contact'.tr(),
|
||||
error: checkList['emergency_contacts'],
|
||||
route: EmergencyContactsRoute()),
|
||||
CheckListItemState(
|
||||
title: 'Roles'.tr(), error: checkList['roles'], route: RoleRoute()),
|
||||
if (checkList.containsKey('equipments'))
|
||||
CheckListItemState(
|
||||
title: 'Equipment'.tr(),
|
||||
error: checkList['equipments'],
|
||||
route: RoleKitFlowRoute(roleKitType: RoleKitType.equipment)),
|
||||
if (checkList.containsKey('uniforms'))
|
||||
CheckListItemState(
|
||||
title: 'Uniform'.tr(),
|
||||
error: checkList['uniforms'],
|
||||
route: RoleKitFlowRoute(roleKitType: RoleKitType.uniform)),
|
||||
CheckListItemState(
|
||||
title: 'Working Area'.tr(),
|
||||
error: checkList['working_areas'],
|
||||
route: WorkingAreaRoute()),
|
||||
CheckListItemState(
|
||||
title: 'Availability'.tr(),
|
||||
error: checkList['schedule'],
|
||||
route: ScheduleRoute()),
|
||||
CheckListItemState(
|
||||
title: 'Bank Account'.tr(),
|
||||
error: checkList['bank_account'],
|
||||
route: const BankAccountFlowRoute()),
|
||||
CheckListItemState(title: 'Wages form'.tr(), error: checkList['']),
|
||||
CheckListItemState(
|
||||
title: 'Certificates'.tr(),
|
||||
error: checkList['certificates'],
|
||||
route: const CertificatesRoute()),
|
||||
CheckListItemState(title: 'Background check'.tr(), error: checkList['']),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:krow/features/check_list/domain/bloc/check_list_state.dart';
|
||||
|
||||
@immutable
|
||||
sealed class CheckListEvent {
|
||||
const CheckListEvent();
|
||||
}
|
||||
|
||||
class CheckListEventFetch extends CheckListEvent {
|
||||
const CheckListEventFetch();
|
||||
}
|
||||
|
||||
class CheckForListUpdateEvent extends CheckListEvent {
|
||||
final CheckListItemState editedItem;
|
||||
|
||||
const CheckForListUpdateEvent({required this.editedItem});
|
||||
}
|
||||
|
||||
class CheckListEventSubmit extends CheckListEvent {
|
||||
const CheckListEventSubmit();
|
||||
}
|
||||
|
||||
class CheckListEventAgree extends CheckListEvent {
|
||||
const CheckListEventAgree();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class CheckListState {
|
||||
final List<CheckListItemState> checkListItems;
|
||||
final bool isSubmitted;
|
||||
final bool isAgree;
|
||||
final bool isSubmitEnabled;
|
||||
final bool loading;
|
||||
|
||||
const CheckListState({
|
||||
this.checkListItems = const [],
|
||||
this.isSubmitted = false,
|
||||
this.isAgree = false,
|
||||
this.loading = false,
|
||||
this.isSubmitEnabled = false,
|
||||
});
|
||||
|
||||
CheckListState copyWith({
|
||||
List<CheckListItemState>? checkListItems,
|
||||
bool? isSubmitted,
|
||||
bool? isAgree,
|
||||
bool? loading,
|
||||
bool? isSubmitEnabled,
|
||||
}) {
|
||||
return CheckListState(
|
||||
checkListItems: checkListItems ?? this.checkListItems,
|
||||
isSubmitted: isSubmitted ?? this.isSubmitted,
|
||||
isAgree: isAgree ?? this.isAgree,
|
||||
loading: loading ?? false,
|
||||
isSubmitEnabled: isSubmitEnabled ?? this.isSubmitEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
bool get hasErrors => checkListItems.any((element) => element.error != null);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class CheckListItemState {
|
||||
final String title;
|
||||
final String? error;
|
||||
final PageRouteInfo? route;
|
||||
|
||||
const CheckListItemState({required this.title, this.route, this.error});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
abstract class CheckListRepository {
|
||||
Stream<Map<String,dynamic>> getCheckList();
|
||||
|
||||
Future<Map<String, dynamic>> getCheckListUpdate();
|
||||
|
||||
Future<void> submitVerification();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@RoutePage()
|
||||
class CheckListFlowScreen extends StatelessWidget {
|
||||
const CheckListFlowScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const AutoRouter();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/application/di/injectable.dart';
|
||||
import 'package:krow/core/application/routing/routes.gr.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/check_box.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
|
||||
import 'package:krow/core/sevices/auth_state_service/auth_service.dart';
|
||||
import 'package:krow/features/check_list/domain/bloc/check_list_bloc.dart';
|
||||
import 'package:krow/features/check_list/domain/bloc/check_list_event.dart';
|
||||
import 'package:krow/features/check_list/domain/bloc/check_list_state.dart';
|
||||
import 'package:krow/features/check_list/presentation/widgets/check_list_display_widget.dart';
|
||||
|
||||
@RoutePage()
|
||||
class CheckListScreen extends StatelessWidget implements AutoRouteWrapper {
|
||||
const CheckListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget wrappedRoute(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => CheckListBloc()..add(const CheckListEventFetch()),
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: KwAppBar(),
|
||||
body: BlocListener<CheckListBloc, CheckListState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.isSubmitted != current.isSubmitted,
|
||||
listener: (context, state) {
|
||||
if (state.isSubmitted) {
|
||||
context.router.push(const WaitingValidationRoute());
|
||||
}
|
||||
},
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: ListView(
|
||||
primary: false,
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(
|
||||
'finalize_your_profile'.tr(),
|
||||
style: AppTextStyles.headingH1,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'check_profile_verification'.tr(),
|
||||
style: AppTextStyles.bodyMediumReg
|
||||
.copyWith(color: AppColors.blackGray),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const CheckListDisplayWidget(),
|
||||
const Gap(36),
|
||||
const _AgreementWidget(),
|
||||
const Gap(36),
|
||||
const _SubmitButtonWidget(),
|
||||
const Gap(36),
|
||||
RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(
|
||||
text: '${'not_you'.tr()} ',
|
||||
style: AppTextStyles.bodyMediumReg
|
||||
.copyWith(color: AppColors.blackGray),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'log_out'.tr(),
|
||||
style: AppTextStyles.bodyMediumSmb.copyWith(
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
getIt<AuthService>().logout();
|
||||
context.router.replace(const AuthFlowRoute());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AgreementWidget extends StatelessWidget {
|
||||
const _AgreementWidget();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
context.read<CheckListBloc>().add(const CheckListEventAgree());
|
||||
},
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
BlocSelector<CheckListBloc, CheckListState, bool>(
|
||||
selector: (state) => state.isAgree,
|
||||
builder: (context, isAgree) {
|
||||
return KWCheckBox(value: isAgree);
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${'i_agree_to_the'.tr()} ',
|
||||
style: AppTextStyles.bodyMediumMed,
|
||||
),
|
||||
TextSpan(
|
||||
text: 'terms_and_conditions'.tr(),
|
||||
style: AppTextStyles.bodyMediumSmb.copyWith(
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
//TODO(Sleep): Handle Terms and Conditions tap
|
||||
},
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ${'and'.tr()} ',
|
||||
style: AppTextStyles.bodyMediumMed,
|
||||
),
|
||||
TextSpan(
|
||||
text: 'privacy_policy'.tr(),
|
||||
style: AppTextStyles.bodyMediumSmb.copyWith(
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
//TODO(Sleep): Handle Privacy Policy tap
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SubmitButtonWidget extends StatelessWidget {
|
||||
const _SubmitButtonWidget();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<CheckListBloc, CheckListState>(
|
||||
buildWhen: (previous, current) {
|
||||
return previous.isAgree != current.isAgree ||
|
||||
previous.hasErrors != current.hasErrors;
|
||||
}, builder: (context, state) {
|
||||
return KwButton.primary(
|
||||
disabled: !state.isAgree || state.hasErrors,
|
||||
label: '${'submit_profile_verification'.tr()} ',
|
||||
onPressed: () {
|
||||
context.read<CheckListBloc>().add(const CheckListEventSubmit());
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/data/static/contacts_data.dart';
|
||||
import 'package:krow/core/presentation/gen/assets.gen.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/core/presentation/widgets/contact_icon_button.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:whatsapp_unilink/whatsapp_unilink.dart';
|
||||
|
||||
@RoutePage()
|
||||
class WaitingValidationScreen extends StatefulWidget {
|
||||
const WaitingValidationScreen({super.key});
|
||||
|
||||
@override
|
||||
State<WaitingValidationScreen> createState() =>
|
||||
_WaitingValidationScreenState();
|
||||
}
|
||||
|
||||
class _WaitingValidationScreenState extends State<WaitingValidationScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: AppColors.bgColorDark,
|
||||
child: SvgPicture.asset(
|
||||
Assets.images.bg.path,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: buildAppBar(),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 120, left: 16, right: 16),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: KwBoxDecorations.primaryDark,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Assets.images.waitingValidation.coffeeBreak.svg(),
|
||||
const Gap(24),
|
||||
Text(
|
||||
'your_account_is_being_verified'.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTextStyles.headingH1.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'waiting_for_email_interview'.tr(),
|
||||
style: AppTextStyles.bodyMediumReg.copyWith(
|
||||
color: AppColors.textPrimaryInverted,
|
||||
),
|
||||
),
|
||||
const Gap(24),
|
||||
Text(
|
||||
'${'contact_support_via'.tr()}:',
|
||||
style: AppTextStyles.bodyMediumMed.copyWith(
|
||||
color: AppColors.primaryYellow,
|
||||
),
|
||||
),
|
||||
const Gap(12),
|
||||
buildContactsGroup()
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Row buildContactsGroup() {
|
||||
return Row(
|
||||
spacing: 24,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ContactIconButton(
|
||||
icon: Assets.images.waitingValidation.call,
|
||||
onTap: () {
|
||||
launchUrlString('tel:${ContactsData.supportPhone}');
|
||||
},
|
||||
),
|
||||
ContactIconButton(
|
||||
icon: Assets.images.waitingValidation.sms,
|
||||
onTap: () {
|
||||
launchUrlString('mailto:${ContactsData.supportEmail}');
|
||||
},
|
||||
),
|
||||
ContactIconButton(
|
||||
icon: Assets.images.waitingValidation.whatsapp,
|
||||
onTap: () {
|
||||
const link = WhatsAppUnilink(
|
||||
phoneNumber: ContactsData.supportPhone,
|
||||
text: 'Hey!',
|
||||
);
|
||||
launchUrlString(link.toString());
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
AppBar buildAppBar() {
|
||||
return KwAppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
iconColorStyle: AppBarIconColorStyle.inverted,
|
||||
showNotification: false,
|
||||
contentColor: AppColors.primaryYellow,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/check_box.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/check_box_card.dart';
|
||||
import 'package:krow/features/check_list/domain/bloc/check_list_bloc.dart';
|
||||
import 'package:krow/features/check_list/domain/bloc/check_list_event.dart';
|
||||
import 'package:krow/features/check_list/domain/bloc/check_list_state.dart';
|
||||
|
||||
class CheckListDisplayWidget extends StatelessWidget {
|
||||
const CheckListDisplayWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<CheckListBloc, CheckListState>(
|
||||
buildWhen: (previous, current) {
|
||||
return previous.checkListItems != current.checkListItems;
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final item in state.checkListItems)
|
||||
CheckBoxCard(
|
||||
title: item.title,
|
||||
isChecked: item.error == null,
|
||||
checkBoxStyle: item.error != null
|
||||
? CheckBoxStyle.black
|
||||
: CheckBoxStyle.green,
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
trailing: true,
|
||||
errorMessage: item.error,
|
||||
onTap: () {
|
||||
final route = item.route;
|
||||
if (route == null) return;
|
||||
|
||||
context.router.push<void>(route).then((_) {
|
||||
if (!context.mounted) return;
|
||||
|
||||
context
|
||||
.read<CheckListBloc>()
|
||||
.add(CheckForListUpdateEvent(editedItem: item));
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
const String _staffPaymentFields = '''
|
||||
id
|
||||
rate
|
||||
assignment {
|
||||
clock_in
|
||||
clock_out
|
||||
start_at
|
||||
end_at
|
||||
break_in
|
||||
break_out
|
||||
position {
|
||||
shift {
|
||||
event {
|
||||
name
|
||||
date
|
||||
business {
|
||||
id
|
||||
name
|
||||
avatar
|
||||
}
|
||||
}
|
||||
}
|
||||
business_skill {
|
||||
skill {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
work_hours
|
||||
amount
|
||||
status
|
||||
paid_at
|
||||
created_at
|
||||
updated_at
|
||||
''';
|
||||
|
||||
const String getWorkSummaryQuerySchema = '''
|
||||
query GetWorkSummary {
|
||||
staff_work_summary {
|
||||
weekly_hours
|
||||
monthly_hours
|
||||
weekly_earnings
|
||||
monthly_earnings
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
const String getPaymentsQuerySchema = '''
|
||||
query GetStaffPayments (\$status: StaffPaymentStatusInput!, \$first: Int!, \$after: String) {
|
||||
staff_payments(status: \$status, first: \$first, after: \$after) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
edges {
|
||||
node {
|
||||
$_staffPaymentFields
|
||||
}
|
||||
cursor
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
const String confirmPaymentMutationSchema = '''
|
||||
mutation ConfirmStaffPayment (\$id: ID!) {
|
||||
confirm_staff_payment(id: \$id) {
|
||||
$_staffPaymentFields
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
const String declinePaymentMutationSchema = '''
|
||||
mutation DeclineStaffPayment (\$id: ID!, \$reason: String!, \$details: String) {
|
||||
decline_staff_payment(id: \$id, reason: \$reason, details: \$details) {
|
||||
$_staffPaymentFields
|
||||
}
|
||||
}
|
||||
''';
|
||||
@@ -0,0 +1,67 @@
|
||||
class EarningModel {
|
||||
EarningModel({
|
||||
required this.id,
|
||||
required this.rate,
|
||||
required this.businessName,
|
||||
required this.businessAvatar,
|
||||
required this.businessSkill,
|
||||
required this.workHours,
|
||||
required this.amount,
|
||||
required this.status,
|
||||
required this.paidAt,
|
||||
required this.clockInAt,
|
||||
required this.clockOutAt,
|
||||
this.breakIn,
|
||||
this.breakOut,
|
||||
required this.eventName,
|
||||
required this.eventDate,
|
||||
});
|
||||
|
||||
factory EarningModel.fromJson(Map<String, dynamic> json) {
|
||||
final assignment = json['assignment'] as Map<String, dynamic>;
|
||||
final positionData = assignment['position'] as Map<String, dynamic>;
|
||||
final eventData = positionData['shift']['event'] as Map<String, dynamic>;
|
||||
final businessData = eventData['business'] as Map<String, dynamic>;
|
||||
|
||||
return EarningModel(
|
||||
id: json['id'] as String? ?? '',
|
||||
rate: (json['rate'] as num? ?? 0).toDouble(),
|
||||
businessName: businessData['name'] as String? ?? '',
|
||||
businessAvatar: businessData['avatar'] as String? ?? '',
|
||||
businessSkill:
|
||||
positionData['business_skill']['skill']['name'] as String? ?? '',
|
||||
workHours: (json['work_hours'] as num? ?? 0).toDouble(),
|
||||
amount: (json['amount'] as num? ?? 0).toDouble(),
|
||||
status: json['status'] as String? ?? 'failed',
|
||||
paidAt: DateTime.tryParse(
|
||||
json['paid_at'] as String? ?? '',
|
||||
),
|
||||
clockInAt: DateTime.parse(
|
||||
assignment['clock_in'] ?? assignment['start_at'] as String,
|
||||
),
|
||||
clockOutAt: DateTime.parse(
|
||||
assignment['clock_out'] ?? assignment['end_at'] as String,
|
||||
),
|
||||
breakIn: DateTime.tryParse(assignment['break_in'] ?? ''),
|
||||
breakOut: DateTime.tryParse(assignment['break_out'] ?? ''),
|
||||
eventName: eventData['name'] as String? ?? '',
|
||||
eventDate: DateTime.parse(eventData['date'] as String? ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final double rate;
|
||||
final String businessName;
|
||||
final String businessAvatar;
|
||||
final String businessSkill;
|
||||
final double workHours;
|
||||
final double amount;
|
||||
final String status;
|
||||
final DateTime? paidAt;
|
||||
final DateTime clockInAt;
|
||||
final DateTime clockOutAt;
|
||||
final DateTime? breakIn;
|
||||
final DateTime? breakOut;
|
||||
final String eventName;
|
||||
final DateTime eventDate;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
class EarningsSummaryModel {
|
||||
EarningsSummaryModel({
|
||||
required this.totalEarningsByWeek,
|
||||
required this.totalEarningsByMonth,
|
||||
required this.totalWorkedHoursByWeek,
|
||||
required this.totalWorkedHoursByMonth,
|
||||
required this.payoutByWeek,
|
||||
required this.payoutByMonth,
|
||||
required this.startDatePeriod,
|
||||
required this.endDatePeriod,
|
||||
required this.maxEarningInPeriod,
|
||||
required this.minEarningInPeriod,
|
||||
});
|
||||
|
||||
//TODO: Additional fields that are used in the Earnings History screen are for now returning default values.
|
||||
factory EarningsSummaryModel.fromJson(Map<String, dynamic> json) {
|
||||
final time = DateTime.now();
|
||||
return EarningsSummaryModel(
|
||||
totalEarningsByWeek: (json['weekly_earnings'] as num?)?.toDouble() ?? 0,
|
||||
totalEarningsByMonth: (json['monthly_earnings'] as num?)?.toDouble() ?? 0,
|
||||
totalWorkedHoursByWeek: (json['weekly_hours'] as num?)?.toDouble() ?? 0,
|
||||
totalWorkedHoursByMonth: (json['monthly_hours'] as num?)?.toDouble() ?? 0,
|
||||
payoutByWeek: 0,
|
||||
payoutByMonth: 0,
|
||||
startDatePeriod: DateTime(time.year, time.month),
|
||||
endDatePeriod: DateTime(time.year, time.month, 28),
|
||||
maxEarningInPeriod: 0,
|
||||
minEarningInPeriod: 0,
|
||||
);
|
||||
}
|
||||
|
||||
final double totalEarningsByWeek;
|
||||
final double totalEarningsByMonth;
|
||||
final double totalWorkedHoursByWeek;
|
||||
final double totalWorkedHoursByMonth;
|
||||
final double payoutByWeek;
|
||||
final double payoutByMonth;
|
||||
final DateTime? startDatePeriod;
|
||||
final DateTime? endDatePeriod;
|
||||
final int maxEarningInPeriod;
|
||||
final int minEarningInPeriod;
|
||||
|
||||
EarningsSummaryModel copyWith({
|
||||
double? totalEarningsByWeek,
|
||||
double? totalEarningsByMonth,
|
||||
double? totalWorkedHoursByWeek,
|
||||
double? totalWorkedHoursByMonth,
|
||||
double? payoutByWeek,
|
||||
double? payoutByMonth,
|
||||
DateTime? startDatePeriod,
|
||||
DateTime? endDatePeriod,
|
||||
int? maxEarningInPeriod,
|
||||
int? minEarningInPeriod,
|
||||
}) {
|
||||
return EarningsSummaryModel(
|
||||
totalEarningsByWeek: totalEarningsByWeek ?? this.totalEarningsByWeek,
|
||||
totalEarningsByMonth: totalEarningsByMonth ?? this.totalEarningsByMonth,
|
||||
totalWorkedHoursByWeek:
|
||||
totalWorkedHoursByWeek ?? this.totalWorkedHoursByWeek,
|
||||
totalWorkedHoursByMonth:
|
||||
totalWorkedHoursByMonth ?? this.totalWorkedHoursByMonth,
|
||||
payoutByWeek: payoutByWeek ?? this.payoutByWeek,
|
||||
payoutByMonth: payoutByMonth ?? this.payoutByMonth,
|
||||
startDatePeriod: startDatePeriod,
|
||||
endDatePeriod: endDatePeriod,
|
||||
maxEarningInPeriod: maxEarningInPeriod ?? this.maxEarningInPeriod,
|
||||
minEarningInPeriod: minEarningInPeriod ?? this.minEarningInPeriod,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:graphql_flutter/graphql_flutter.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:krow/core/application/clients/api/api_client.dart';
|
||||
import 'package:krow/core/data/models/pagination_wrapper/pagination_wrapper.dart';
|
||||
import 'package:krow/features/earning/data/earning_qgl.dart';
|
||||
import 'package:krow/features/earning/data/models/earning_model.dart';
|
||||
import 'package:krow/features/earning/data/models/earnings_summary_model.dart';
|
||||
|
||||
@injectable
|
||||
class StaffEarningApiProvider {
|
||||
StaffEarningApiProvider({required ApiClient client}) : _client = client;
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<EarningsSummaryModel> fetchStaffEarningsSummary() async {
|
||||
final QueryResult result = await _client.query(
|
||||
schema: getWorkSummaryQuerySchema,
|
||||
);
|
||||
|
||||
if (result.hasException) {
|
||||
throw Exception(result.exception.toString());
|
||||
}
|
||||
|
||||
return EarningsSummaryModel.fromJson(
|
||||
result.data?['staff_work_summary'] as Map<String, dynamic>? ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
Future<PaginationWrapper<EarningModel>> fetchEarnings({
|
||||
required String status,
|
||||
required int limit,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final QueryResult result = await _client.query(
|
||||
schema: getPaymentsQuerySchema,
|
||||
body: {'status': status, 'first': limit, 'after': cursor},
|
||||
);
|
||||
|
||||
if (result.hasException) {
|
||||
throw Exception(result.exception.toString());
|
||||
}
|
||||
|
||||
return PaginationWrapper<EarningModel>.fromJson(
|
||||
result.data?['staff_payments'] ?? {},
|
||||
EarningModel.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
Future<EarningModel?> _processPaymentMutation({
|
||||
required String schema,
|
||||
required Map<String, dynamic> body,
|
||||
required String mutationName,
|
||||
}) async {
|
||||
final QueryResult result = await _client.mutate(
|
||||
schema: schema,
|
||||
body: body,
|
||||
);
|
||||
|
||||
if (result.hasException) throw Exception(result.exception.toString());
|
||||
|
||||
if (result.data == null) {
|
||||
throw Exception('Payment data is missing on mutation $mutationName');
|
||||
}
|
||||
|
||||
final jsonData = result.data?[mutationName] as Map<String, dynamic>;
|
||||
return EarningModel.fromJson(jsonData);
|
||||
}
|
||||
|
||||
Future<EarningModel?> confirmPayment({required String id}) {
|
||||
return _processPaymentMutation(
|
||||
schema: confirmPaymentMutationSchema,
|
||||
body: {'id': id},
|
||||
mutationName: 'confirm_staff_payment',
|
||||
);
|
||||
}
|
||||
|
||||
Future<EarningModel?> declinePayment({
|
||||
required String id,
|
||||
required String reason,
|
||||
String? details,
|
||||
}) {
|
||||
return _processPaymentMutation(
|
||||
schema: declinePaymentMutationSchema,
|
||||
body: {'id': id, 'reason': reason, 'details': details},
|
||||
mutationName: 'decline_staff_payment',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:krow/features/earning/data/models/earning_model.dart';
|
||||
import 'package:krow/features/earning/data/staff_earning_api_provider.dart';
|
||||
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
|
||||
import 'package:krow/features/earning/domain/entities/earnings_batch_entity.dart';
|
||||
import 'package:krow/features/earning/domain/entities/earnings_summary_entity.dart';
|
||||
import 'package:krow/features/earning/domain/staff_earning_repository.dart';
|
||||
|
||||
@Injectable(as: StaffEarningRepository)
|
||||
class StaffEarningRepositoryImpl implements StaffEarningRepository {
|
||||
StaffEarningRepositoryImpl({
|
||||
required StaffEarningApiProvider apiProvider,
|
||||
}) : _apiProvider = apiProvider;
|
||||
|
||||
final StaffEarningApiProvider _apiProvider;
|
||||
|
||||
EarningShiftEntity _convertEarningModel(EarningModel data) {
|
||||
return EarningShiftEntity(
|
||||
id: data.id,
|
||||
status: EarningStatus.fromString(data.status),
|
||||
businessImageUrl: data.businessAvatar,
|
||||
skillName: data.businessSkill,
|
||||
businessName: data.businessName,
|
||||
totalBreakTime: data.breakIn != null
|
||||
? data.breakOut?.difference(data.breakIn!).inSeconds
|
||||
: null,
|
||||
paymentStatus: 1,
|
||||
earned: data.amount,
|
||||
clockIn: data.clockInAt,
|
||||
clockOut: data.clockOutAt,
|
||||
eventName: data.eventName,
|
||||
eventDate: data.eventDate,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EarningsSummaryEntity> getStaffEarningsData() async {
|
||||
final data = await _apiProvider.fetchStaffEarningsSummary();
|
||||
|
||||
return EarningsSummaryEntity(
|
||||
totalEarningsByWeek: data.totalEarningsByWeek,
|
||||
totalEarningsByMonth: data.totalEarningsByMonth,
|
||||
totalWorkedHoursByWeek: data.totalWorkedHoursByWeek,
|
||||
totalWorkedHoursByMonth: data.totalWorkedHoursByMonth,
|
||||
payoutByWeek: data.payoutByWeek,
|
||||
payoutByMonth: data.payoutByMonth,
|
||||
startDatePeriod: data.startDatePeriod,
|
||||
endDatePeriod: data.endDatePeriod,
|
||||
maxEarningInPeriod: data.maxEarningInPeriod,
|
||||
minEarningInPeriod: data.minEarningInPeriod,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EarningsBatchEntity> getEarningsBatch({
|
||||
required String status,
|
||||
int limit = 10,
|
||||
String? lastEntryCursor,
|
||||
}) async {
|
||||
final paginationInfo = await _apiProvider.fetchEarnings(
|
||||
status: status,
|
||||
limit: limit,
|
||||
cursor: lastEntryCursor,
|
||||
);
|
||||
|
||||
return EarningsBatchEntity(
|
||||
batchStatus: status,
|
||||
hasNextBatch: paginationInfo.pageInfo?.hasNextPage??false,
|
||||
cursor: paginationInfo.pageInfo?.endCursor ??
|
||||
paginationInfo.edges.lastOrNull?.cursor,
|
||||
earnings: paginationInfo.edges.map(
|
||||
(edgeData) {
|
||||
return _convertEarningModel(edgeData.node);
|
||||
},
|
||||
).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EarningShiftEntity?> confirmStaffEarning({
|
||||
required String earningId,
|
||||
}) async {
|
||||
final result = await _apiProvider.confirmPayment(id: earningId);
|
||||
|
||||
if (result == null) return null;
|
||||
|
||||
return _convertEarningModel(result);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EarningShiftEntity?> disputeStaffEarning({
|
||||
required String id,
|
||||
required String reason,
|
||||
String? details,
|
||||
}) async {
|
||||
final result = await _apiProvider.declinePayment(
|
||||
id: id,
|
||||
reason: reason,
|
||||
details: details,
|
||||
);
|
||||
|
||||
if (result == null) return null;
|
||||
|
||||
return _convertEarningModel(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/core/application/di/injectable.dart';
|
||||
import 'package:krow/core/data/enums/pagination_status.dart';
|
||||
import 'package:krow/core/data/enums/state_status.dart';
|
||||
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
|
||||
import 'package:krow/features/earning/domain/entities/earnings_summary_entity.dart';
|
||||
import 'package:krow/features/earning/domain/staff_earning_repository.dart';
|
||||
|
||||
part 'earnings_data.dart';
|
||||
part 'earnings_event.dart';
|
||||
part 'earnings_state.dart';
|
||||
|
||||
class EarningsBloc extends Bloc<EarningsEvent, EarningsState> {
|
||||
EarningsBloc()
|
||||
: super(const EarningsState.initial()
|
||||
..copyWith(
|
||||
tabs: defaultEarningTabs,
|
||||
)) {
|
||||
on<EarningsInitEvent>(_onInit);
|
||||
on<EarningsSwitchBalancePeriodEvent>(_onSwitchBalancePeriod);
|
||||
on<EarningsTabChangedEvent>(_onTabChanged);
|
||||
on<LoadAdditionalEarnings>(_onLoadAdditionalEarnings);
|
||||
on<ReloadCurrentEarnings>(_onReloadCurrentEarnings);
|
||||
on<ConfirmStaffEarning>(_onConfirmStaffEarning);
|
||||
on<DisputeStaffEarning>(_onDisputeStaffEarning);
|
||||
}
|
||||
|
||||
final _earningRepository = getIt<StaffEarningRepository>();
|
||||
|
||||
Future<void> _onInit(
|
||||
EarningsInitEvent event,
|
||||
Emitter<EarningsState> emit,
|
||||
) async {
|
||||
add(const LoadAdditionalEarnings());
|
||||
|
||||
emit(state.copyWith(status: StateStatus.loading));
|
||||
EarningsSummaryEntity? earningsData;
|
||||
try {
|
||||
earningsData = await _earningRepository.getStaffEarningsData();
|
||||
} catch (except) {
|
||||
log('Error in EarningsBloc, on EarningsInitEvent', error: except);
|
||||
}
|
||||
emit(state.copyWith(earnings: earningsData, status: StateStatus.idle));
|
||||
}
|
||||
|
||||
void _onSwitchBalancePeriod(
|
||||
EarningsSwitchBalancePeriodEvent event,
|
||||
Emitter<EarningsState> emit,
|
||||
) {
|
||||
emit(state.copyWith(balancePeriod: event.period));
|
||||
}
|
||||
|
||||
void _onTabChanged(
|
||||
EarningsTabChangedEvent event,
|
||||
Emitter<EarningsState> emit,
|
||||
) {
|
||||
emit(state.copyWith(tabIndex: event.tabIndex));
|
||||
|
||||
if (state.currentTab.status == PaginationStatus.initial) {
|
||||
add(const LoadAdditionalEarnings());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLoadAdditionalEarnings(
|
||||
LoadAdditionalEarnings event,
|
||||
Emitter<EarningsState> emit,
|
||||
) async {
|
||||
if (!state.currentTab.status.allowLoad) return;
|
||||
emit(state.updateCurrentTab(status: PaginationStatus.loading));
|
||||
|
||||
final tabIndex = state.tabIndex;
|
||||
try {
|
||||
final earningsBatch = await _earningRepository.getEarningsBatch(
|
||||
status: state.currentTab.loadingKey,
|
||||
lastEntryCursor: state.currentTab.paginationCursor,
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWithTab(
|
||||
tabIndex: tabIndex,
|
||||
tab: state.tabs[tabIndex].copyWith(
|
||||
status: earningsBatch.hasNextBatch
|
||||
? PaginationStatus.idle
|
||||
: PaginationStatus.end,
|
||||
items: [...state.tabs[tabIndex].items, ...earningsBatch.earnings],
|
||||
hasMoreItems: earningsBatch.hasNextBatch,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (except) {
|
||||
log(
|
||||
'Error in EarningsBloc, on LoadAdditionalEarnings',
|
||||
error: except,
|
||||
);
|
||||
emit(
|
||||
state.copyWithTab(
|
||||
tabIndex: tabIndex,
|
||||
tab: state.tabs[tabIndex].copyWith(
|
||||
status: PaginationStatus.error,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.tabs[tabIndex].status == PaginationStatus.loading) {
|
||||
emit(
|
||||
state.copyWithTab(
|
||||
tabIndex: tabIndex,
|
||||
tab: state.tabs[tabIndex].copyWith(
|
||||
status: PaginationStatus.idle,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onReloadCurrentEarnings(
|
||||
ReloadCurrentEarnings event,
|
||||
Emitter<EarningsState> emit,
|
||||
) {
|
||||
emit(
|
||||
state.copyWithTab(
|
||||
tab: EarningsTabState.initial(
|
||||
label: state.currentTab.label,
|
||||
loadingKey: state.currentTab.loadingKey,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
add(const LoadAdditionalEarnings());
|
||||
}
|
||||
|
||||
Future<void> _onEarningAction(
|
||||
Future<EarningShiftEntity?> earningFuture,
|
||||
String eventName,
|
||||
Emitter<EarningsState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: StateStatus.loading));
|
||||
|
||||
EarningShiftEntity? earning;
|
||||
try {
|
||||
earning = await earningFuture;
|
||||
} catch (except) {
|
||||
log(
|
||||
'Error in EarningsBloc, on $eventName',
|
||||
error: except,
|
||||
);
|
||||
}
|
||||
|
||||
var tabData = state.currentTab;
|
||||
if (earning != null) {
|
||||
final earningIndex =
|
||||
state.currentTab.items.indexWhere((item) => item.id == earning?.id);
|
||||
|
||||
if (earningIndex >= 0) {
|
||||
tabData = state.currentTab.copyWith(
|
||||
items: List.from(state.currentTab.items)..[earningIndex] = earning,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWithTab(
|
||||
status: StateStatus.idle,
|
||||
tab: tabData,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onConfirmStaffEarning(
|
||||
ConfirmStaffEarning event,
|
||||
Emitter<EarningsState> emit,
|
||||
) {
|
||||
return _onEarningAction(
|
||||
_earningRepository.confirmStaffEarning(
|
||||
earningId: event.earningId,
|
||||
),
|
||||
'ConfirmStaffEarning',
|
||||
emit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onDisputeStaffEarning(
|
||||
DisputeStaffEarning event,
|
||||
Emitter<EarningsState> emit,
|
||||
) async {
|
||||
return _onEarningAction(
|
||||
_earningRepository.disputeStaffEarning(
|
||||
id: event.earningId,
|
||||
reason: event.reason,
|
||||
details: event.details,
|
||||
),
|
||||
'DeclineStaffEarning',
|
||||
emit,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
part of 'earnings_bloc.dart';
|
||||
|
||||
enum BalancePeriod {
|
||||
week,
|
||||
month,
|
||||
}
|
||||
|
||||
class EarningsTabState {
|
||||
const EarningsTabState({
|
||||
required this.label,
|
||||
required this.loadingKey,
|
||||
required this.items,
|
||||
this.status = PaginationStatus.idle,
|
||||
this.hasMoreItems = true,
|
||||
this.paginationCursor,
|
||||
});
|
||||
|
||||
const EarningsTabState.initial({
|
||||
required this.label,
|
||||
required this.loadingKey,
|
||||
this.items = const [],
|
||||
this.status = PaginationStatus.initial,
|
||||
this.hasMoreItems = true,
|
||||
this.paginationCursor,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String loadingKey;
|
||||
final List<EarningShiftEntity> items;
|
||||
final PaginationStatus status;
|
||||
final bool hasMoreItems;
|
||||
final String? paginationCursor;
|
||||
|
||||
EarningsTabState copyWith({
|
||||
List<EarningShiftEntity>? items,
|
||||
PaginationStatus? status,
|
||||
bool? hasMoreItems,
|
||||
String? label,
|
||||
String? loadingKey,
|
||||
String? paginationCursor,
|
||||
}) {
|
||||
return EarningsTabState(
|
||||
loadingKey: loadingKey ?? this.loadingKey,
|
||||
items: items ?? this.items,
|
||||
status: status ?? this.status,
|
||||
hasMoreItems: hasMoreItems ?? this.hasMoreItems,
|
||||
label: label ?? this.label,
|
||||
paginationCursor: paginationCursor ?? this.paginationCursor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultEarningTabs = [
|
||||
EarningsTabState.initial(
|
||||
label: 'new_earning',
|
||||
loadingKey: 'new' // Confirmed by admin
|
||||
),
|
||||
EarningsTabState.initial(
|
||||
label: 'confirmed',
|
||||
loadingKey: 'confirmed', // Confirmed by staff
|
||||
),
|
||||
EarningsTabState.initial(
|
||||
label: 'disputed',
|
||||
loadingKey: 'disputed', // Declined by staff
|
||||
),
|
||||
EarningsTabState.initial(
|
||||
label: 'sent',
|
||||
loadingKey: 'sent', // Should remain sent
|
||||
),
|
||||
EarningsTabState.initial(
|
||||
label: 'received',
|
||||
loadingKey: 'paid', // Should remain paid
|
||||
),
|
||||
];
|
||||
|
||||
// $of = match ($status) {
|
||||
// 'new' => fn(Builder $q) => $q->whereIn('status', [StaffPaymentStatus::new]),
|
||||
// 'confirmed' => fn(Builder $q) => $q->whereIn('status', [StaffPaymentStatus::confirmed_by_admin]),
|
||||
// 'pending' => fn(Builder $q) => $q->whereIn('status', [
|
||||
// StaffPaymentStatus::decline_by_staff,
|
||||
// StaffPaymentStatus::confirmed_by_staff,
|
||||
// ]),
|
||||
// 'sent' => fn(Builder $q) => $q->whereIn('status', [StaffPaymentStatus::sent]),
|
||||
// 'paid' => fn(Builder $q) => $q->whereIn('status', [StaffPaymentStatus::paid]),
|
||||
// };
|
||||
@@ -0,0 +1,48 @@
|
||||
part of 'earnings_bloc.dart';
|
||||
|
||||
@immutable
|
||||
sealed class EarningsEvent {
|
||||
const EarningsEvent();
|
||||
}
|
||||
|
||||
class EarningsInitEvent extends EarningsEvent {
|
||||
const EarningsInitEvent();
|
||||
}
|
||||
|
||||
class EarningsSwitchBalancePeriodEvent extends EarningsEvent {
|
||||
final BalancePeriod period;
|
||||
|
||||
const EarningsSwitchBalancePeriodEvent({required this.period});
|
||||
}
|
||||
|
||||
class EarningsTabChangedEvent extends EarningsEvent {
|
||||
final int tabIndex;
|
||||
|
||||
const EarningsTabChangedEvent({required this.tabIndex});
|
||||
}
|
||||
|
||||
class LoadAdditionalEarnings extends EarningsEvent {
|
||||
const LoadAdditionalEarnings();
|
||||
}
|
||||
|
||||
class ReloadCurrentEarnings extends EarningsEvent {
|
||||
const ReloadCurrentEarnings();
|
||||
}
|
||||
|
||||
class ConfirmStaffEarning extends EarningsEvent {
|
||||
const ConfirmStaffEarning(this.earningId);
|
||||
|
||||
final String earningId;
|
||||
}
|
||||
|
||||
class DisputeStaffEarning extends EarningsEvent {
|
||||
const DisputeStaffEarning({
|
||||
required this.earningId,
|
||||
required this.reason,
|
||||
required this.details,
|
||||
});
|
||||
|
||||
final String earningId;
|
||||
final String reason;
|
||||
final String details;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
part of 'earnings_bloc.dart';
|
||||
|
||||
@immutable
|
||||
class EarningsState {
|
||||
const EarningsState({
|
||||
required this.status,
|
||||
required this.tabIndex,
|
||||
required this.balancePeriod,
|
||||
required this.earnings,
|
||||
required this.tabs,
|
||||
});
|
||||
|
||||
const EarningsState.initial({
|
||||
this.status = StateStatus.idle,
|
||||
this.tabIndex = 0,
|
||||
this.balancePeriod = BalancePeriod.week,
|
||||
this.earnings = const EarningsSummaryEntity.empty(),
|
||||
this.tabs = defaultEarningTabs,
|
||||
});
|
||||
|
||||
final StateStatus status;
|
||||
final int tabIndex;
|
||||
final BalancePeriod balancePeriod;
|
||||
final EarningsSummaryEntity earnings;
|
||||
final List<EarningsTabState> tabs;
|
||||
|
||||
EarningsTabState get currentTab => tabs[tabIndex];
|
||||
|
||||
double get totalEarnings {
|
||||
return balancePeriod == BalancePeriod.week
|
||||
? earnings.totalEarningsByWeek
|
||||
: earnings.totalEarningsByMonth;
|
||||
}
|
||||
|
||||
double get totalHours {
|
||||
return balancePeriod == BalancePeriod.week
|
||||
? earnings.totalWorkedHoursByWeek
|
||||
: earnings.totalWorkedHoursByMonth;
|
||||
}
|
||||
|
||||
double get totalPayout {
|
||||
return balancePeriod == BalancePeriod.week
|
||||
? earnings.payoutByWeek
|
||||
: earnings.payoutByMonth;
|
||||
}
|
||||
|
||||
EarningsState copyWith({
|
||||
StateStatus? status,
|
||||
int? tabIndex,
|
||||
BalancePeriod? balancePeriod,
|
||||
EarningsSummaryEntity? earnings,
|
||||
List<EarningsTabState>? tabs,
|
||||
}) {
|
||||
return EarningsState(
|
||||
status: status ?? this.status,
|
||||
tabIndex: tabIndex ?? this.tabIndex,
|
||||
balancePeriod: balancePeriod ?? this.balancePeriod,
|
||||
earnings: earnings ?? this.earnings,
|
||||
tabs: tabs ?? this.tabs,
|
||||
);
|
||||
}
|
||||
|
||||
EarningsState copyWithTab({
|
||||
required EarningsTabState tab,
|
||||
int? tabIndex,
|
||||
StateStatus? status,
|
||||
}) {
|
||||
return copyWith(
|
||||
tabs: List.from(tabs)..[tabIndex ?? this.tabIndex] = tab,
|
||||
status: status,
|
||||
);
|
||||
}
|
||||
|
||||
EarningsState updateCurrentTab({
|
||||
List<EarningShiftEntity>? items,
|
||||
PaginationStatus? status,
|
||||
bool? hasMoreItems,
|
||||
String? label,
|
||||
String? paginationCursor,
|
||||
}) {
|
||||
return copyWith(
|
||||
tabs: List.from(tabs)
|
||||
..[tabIndex] = currentTab.copyWith(
|
||||
items: items,
|
||||
status: status,
|
||||
hasMoreItems: hasMoreItems,
|
||||
label: label,
|
||||
paginationCursor: paginationCursor,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
class EarningDisputeInfo {
|
||||
String reason = '';
|
||||
String details = '';
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
class EarningShiftEntity {
|
||||
final String id;
|
||||
final EarningStatus status;
|
||||
final String businessImageUrl;
|
||||
final String skillName;
|
||||
final String businessName;
|
||||
final int? totalBreakTime;
|
||||
final int? paymentStatus;
|
||||
final double? earned;
|
||||
final DateTime? clockIn;
|
||||
final DateTime? clockOut;
|
||||
final String eventName;
|
||||
final DateTime eventDate;
|
||||
|
||||
EarningShiftEntity({
|
||||
required this.id,
|
||||
required this.status,
|
||||
required this.businessImageUrl,
|
||||
required this.skillName,
|
||||
required this.businessName,
|
||||
required this.totalBreakTime,
|
||||
required this.paymentStatus,
|
||||
required this.earned,
|
||||
required this.clockIn,
|
||||
required this.clockOut,
|
||||
required this.eventName,
|
||||
required this.eventDate,
|
||||
});
|
||||
|
||||
int get paymentProgressStep {
|
||||
return switch (status) {
|
||||
EarningStatus.pending => 0, // corresponds to Pending Sent
|
||||
EarningStatus.processing => 1, // corresponds to Pending Payment
|
||||
EarningStatus.paid => 2, // corresponds to Payment Received
|
||||
_ => -1, // don't show active step
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
enum EarningStatus {
|
||||
new_,
|
||||
pending,
|
||||
confirmedByAdmin,
|
||||
confirmedByStaff,
|
||||
declineByStaff,
|
||||
processing,
|
||||
paid,
|
||||
failed,
|
||||
canceled;
|
||||
|
||||
static EarningStatus fromString(value) {
|
||||
return switch (value) {
|
||||
'new' => new_,
|
||||
'confirmed_by_admin' => confirmedByAdmin,
|
||||
'confirmed_by_staff' => confirmedByStaff,
|
||||
'decline_by_staff' => declineByStaff,
|
||||
'pending' => pending,
|
||||
'paid' => paid,
|
||||
'failed' => failed,
|
||||
'canceled' => canceled,
|
||||
_ => canceled,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
|
||||
|
||||
class EarningsBatchEntity {
|
||||
EarningsBatchEntity({
|
||||
required this.batchStatus,
|
||||
required this.hasNextBatch,
|
||||
required this.earnings,
|
||||
this.cursor,
|
||||
});
|
||||
|
||||
final String batchStatus;
|
||||
final bool hasNextBatch;
|
||||
final List<EarningShiftEntity> earnings;
|
||||
final String? cursor;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class EarningsSummaryEntity {
|
||||
const EarningsSummaryEntity({
|
||||
required this.totalEarningsByWeek,
|
||||
required this.totalEarningsByMonth,
|
||||
required this.totalWorkedHoursByWeek,
|
||||
required this.totalWorkedHoursByMonth,
|
||||
required this.payoutByWeek,
|
||||
required this.payoutByMonth,
|
||||
required this.startDatePeriod,
|
||||
required this.endDatePeriod,
|
||||
required this.maxEarningInPeriod,
|
||||
required this.minEarningInPeriod,
|
||||
});
|
||||
|
||||
const EarningsSummaryEntity.empty({
|
||||
this.totalEarningsByWeek = 0,
|
||||
this.totalEarningsByMonth = 0,
|
||||
this.totalWorkedHoursByWeek = 0,
|
||||
this.totalWorkedHoursByMonth = 0,
|
||||
this.payoutByWeek = 0,
|
||||
this.payoutByMonth = 0,
|
||||
this.startDatePeriod,
|
||||
this.endDatePeriod,
|
||||
this.maxEarningInPeriod = 0,
|
||||
this.minEarningInPeriod = 0,
|
||||
});
|
||||
|
||||
final double totalEarningsByWeek;
|
||||
final double totalEarningsByMonth;
|
||||
final double totalWorkedHoursByWeek;
|
||||
final double totalWorkedHoursByMonth;
|
||||
final double payoutByWeek;
|
||||
final double payoutByMonth;
|
||||
final DateTime? startDatePeriod;
|
||||
final DateTime? endDatePeriod;
|
||||
final int maxEarningInPeriod;
|
||||
final int minEarningInPeriod;
|
||||
|
||||
EarningsSummaryEntity copyWith({
|
||||
double? totalEarningsByWeek,
|
||||
double? totalEarningsByMonth,
|
||||
double? totalWorkedHoursByWeek,
|
||||
double? totalWorkedHoursByMonth,
|
||||
double? payoutByWeek,
|
||||
double? payoutByMonth,
|
||||
DateTime? startDatePeriod,
|
||||
DateTime? endDatePeriod,
|
||||
int? maxEarningInPeriod,
|
||||
int? minEarningInPeriod,
|
||||
}) {
|
||||
return EarningsSummaryEntity(
|
||||
totalEarningsByWeek: totalEarningsByWeek ?? this.totalEarningsByWeek,
|
||||
totalEarningsByMonth: totalEarningsByMonth ?? this.totalEarningsByMonth,
|
||||
totalWorkedHoursByWeek:
|
||||
totalWorkedHoursByWeek ?? this.totalWorkedHoursByWeek,
|
||||
totalWorkedHoursByMonth:
|
||||
totalWorkedHoursByMonth ?? this.totalWorkedHoursByMonth,
|
||||
payoutByWeek: payoutByWeek ?? this.payoutByWeek,
|
||||
payoutByMonth: payoutByMonth ?? this.payoutByMonth,
|
||||
startDatePeriod: startDatePeriod,
|
||||
endDatePeriod: endDatePeriod,
|
||||
maxEarningInPeriod: maxEarningInPeriod ?? this.maxEarningInPeriod,
|
||||
minEarningInPeriod: minEarningInPeriod ?? this.minEarningInPeriod,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
|
||||
import 'package:krow/features/earning/domain/entities/earnings_batch_entity.dart';
|
||||
import 'package:krow/features/earning/domain/entities/earnings_summary_entity.dart';
|
||||
|
||||
abstract interface class StaffEarningRepository {
|
||||
Future<EarningsSummaryEntity> getStaffEarningsData();
|
||||
|
||||
Future<EarningsBatchEntity> getEarningsBatch({
|
||||
required String status,
|
||||
int limit = 10,
|
||||
String? lastEntryCursor,
|
||||
});
|
||||
|
||||
Future<EarningShiftEntity?> confirmStaffEarning({required String earningId});
|
||||
|
||||
Future<EarningShiftEntity?> disputeStaffEarning({
|
||||
required String id,
|
||||
required String reason,
|
||||
String? details,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
|
||||
|
||||
@RoutePage()
|
||||
class EarningsFlowScreen extends StatelessWidget {
|
||||
const EarningsFlowScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (BuildContext context) =>
|
||||
EarningsBloc()..add(const EarningsInitEvent()),
|
||||
child: const AutoRouter(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:auto_route/annotations.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
|
||||
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
|
||||
import 'package:krow/features/earning/presentation/widget/earnings_appbar.dart';
|
||||
import 'package:krow/features/earning/presentation/widget/list_item/earning_card_compact_widget.dart';
|
||||
|
||||
import '../widget/earnings_history_header.dart';
|
||||
|
||||
@RoutePage()
|
||||
class EarningsHistoryScreen extends StatelessWidget {
|
||||
const EarningsHistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: BlocBuilder<EarningsBloc, EarningsState>(
|
||||
builder: (context, state) {
|
||||
List<EarningShiftEntity> items = state.currentTab.items;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const EarningsAppBar(
|
||||
leading: true,
|
||||
child: EarningsHistoryHeader(),
|
||||
),
|
||||
const Gap(24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: Text(
|
||||
'your_shifts'.tr(),
|
||||
style: AppTextStyles.bodySmallMed,
|
||||
),
|
||||
),
|
||||
_buildListView(items),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ListView _buildListView(List<EarningShiftEntity> items) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.only(top: 24),
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
return EarningCardCompactWidget(
|
||||
earningEntity: items[index],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:auto_route/annotations.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
|
||||
import 'package:krow/features/earning/presentation/slivers/bottom_earnings_sliver.dart';
|
||||
import 'package:krow/features/earning/presentation/slivers/earnings_header_sliver.dart';
|
||||
import 'package:krow/features/earning/presentation/slivers/earnings_list_sliver.dart';
|
||||
|
||||
@RoutePage()
|
||||
class EarningsScreen extends StatefulWidget {
|
||||
const EarningsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<EarningsScreen> createState() => _EarningsScreenState();
|
||||
}
|
||||
|
||||
class _EarningsScreenState extends State<EarningsScreen> {
|
||||
final _scrollController = ScrollController();
|
||||
bool allowPagination = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_scrollController.addListener(
|
||||
() {
|
||||
if (allowPagination &&
|
||||
_scrollController.position.userScrollDirection ==
|
||||
ScrollDirection.reverse &&
|
||||
_scrollController.position.extentAfter < 50) {
|
||||
context.read<EarningsBloc>().add(const LoadAdditionalEarnings());
|
||||
allowPagination = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
extendBody: true,
|
||||
body: RefreshIndicator(
|
||||
displacement: 80,
|
||||
backgroundColor: AppColors.bgColorLight,
|
||||
color: AppColors.bgColorDark,
|
||||
triggerMode: RefreshIndicatorTriggerMode.anywhere,
|
||||
onRefresh: () {
|
||||
context.read<EarningsBloc>().add(const ReloadCurrentEarnings());
|
||||
return Future.delayed(const Duration(seconds: 1));
|
||||
},
|
||||
child: BlocListener<EarningsBloc, EarningsState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.currentTab.status != current.currentTab.status,
|
||||
listener: (context, state) {
|
||||
allowPagination = state.currentTab.status.allowLoad;
|
||||
},
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
slivers: [
|
||||
const EarningsHeaderSliver(),
|
||||
const EarningsListSliver(),
|
||||
const BottomEarningsSliver(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/data/enums/pagination_status.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
|
||||
|
||||
class BottomEarningsSliver extends StatelessWidget {
|
||||
const BottomEarningsSliver({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverSafeArea(
|
||||
top: false,
|
||||
sliver: BlocBuilder<EarningsBloc, EarningsState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.currentTab != current.currentTab ||
|
||||
current.currentTab.status != previous.currentTab.status,
|
||||
builder: (context, state) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 60,
|
||||
child: Center(
|
||||
child: switch (state.currentTab.status) {
|
||||
PaginationStatus.initial ||
|
||||
PaginationStatus.loading =>
|
||||
const CircularProgressIndicator(),
|
||||
PaginationStatus.empty => Text(
|
||||
'no_history_section'.tr(),
|
||||
style: AppTextStyles.bodyMediumReg,
|
||||
),
|
||||
PaginationStatus.end => Text(
|
||||
'end_of_payments_history'.tr(),
|
||||
style: AppTextStyles.bodyMediumReg,
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
),
|
||||
),
|
||||
const Gap(20),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_tabs.dart';
|
||||
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
|
||||
import 'package:krow/features/earning/presentation/widget/earnings_appbar.dart';
|
||||
import 'package:krow/features/earning/presentation/widget/earnings_header.dart';
|
||||
|
||||
class EarningsHeaderSliver extends StatelessWidget {
|
||||
const EarningsHeaderSliver({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverList.list(
|
||||
children: [
|
||||
const EarningsAppBar(child: EarningsHeader()),
|
||||
const Gap(24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: Text(
|
||||
'your_shifts'.tr(),
|
||||
style: AppTextStyles.bodySmallMed,
|
||||
),
|
||||
),
|
||||
const Gap(24),
|
||||
BlocBuilder<EarningsBloc, EarningsState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.tabs != current.tabs,
|
||||
builder: (context, state) {
|
||||
return KwTabBar(
|
||||
key: const Key('earnings_tab_bar'),
|
||||
forceScroll: true,
|
||||
tabs: [for (final tab in state.tabs) tab.label.tr()],
|
||||
onTap: (int index) {
|
||||
context
|
||||
.read<EarningsBloc>()
|
||||
.add(EarningsTabChangedEvent(tabIndex: index));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
|
||||
import 'package:krow/features/earning/presentation/widget/list_item/earning_card_widget.dart';
|
||||
|
||||
class EarningsListSliver extends StatelessWidget {
|
||||
const EarningsListSliver({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
sliver: BlocBuilder<EarningsBloc, EarningsState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.tabIndex != current.tabIndex ||
|
||||
previous.currentTab.items != current.currentTab.items,
|
||||
builder: (context, state) {
|
||||
return SliverList.separated(
|
||||
itemCount: state.currentTab.items.length,
|
||||
separatorBuilder: (context, index) => const Gap(12),
|
||||
itemBuilder: (context, index) {
|
||||
return EarningCardWidget(
|
||||
earningEntity: state.currentTab.items[index],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
|
||||
|
||||
class AnimatedSlider extends StatefulWidget {
|
||||
const AnimatedSlider({super.key});
|
||||
|
||||
@override
|
||||
State<AnimatedSlider> createState() => _AnimatedSliderState();
|
||||
}
|
||||
|
||||
class _AnimatedSliderState extends State<AnimatedSlider> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 200, // Adjust the width as needed
|
||||
height: 36, // Adjust the height as needed
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blueGrey.shade400, // Background color
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
BlocBuilder<EarningsBloc, EarningsState>(
|
||||
builder: (context, state) {
|
||||
bool isThisWeekSelected =
|
||||
state.balancePeriod == BalancePeriod.week;
|
||||
|
||||
return AnimatedAlign(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
alignment: isThisWeekSelected
|
||||
? Alignment.centerLeft
|
||||
: Alignment.centerRight,
|
||||
child: Container(
|
||||
width: 90,
|
||||
margin: const EdgeInsets.all(4),
|
||||
// Half the width of the container
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blueGrey.shade900, // Active slider color
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
BlocProvider.of<EarningsBloc>(context)
|
||||
.add(const EarningsSwitchBalancePeriodEvent(
|
||||
period: BalancePeriod.week,
|
||||
));
|
||||
},
|
||||
child: Container(
|
||||
color: Colors.transparent,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'this_week'.tr(),
|
||||
style: AppTextStyles.bodySmallMed
|
||||
.copyWith(color: AppColors.grayWhite),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
BlocProvider.of<EarningsBloc>(context)
|
||||
.add(const EarningsSwitchBalancePeriodEvent(
|
||||
period: BalancePeriod.month,
|
||||
));
|
||||
},
|
||||
child: Container(
|
||||
color: Colors.transparent,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'this_month'.tr(),
|
||||
style: AppTextStyles.bodySmallMed
|
||||
.copyWith(color: AppColors.grayWhite),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_dropdown.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_input.dart';
|
||||
import 'package:krow/features/earning/domain/entities/earning_dispute_info.dart';
|
||||
|
||||
class DisputeFormWidget extends StatelessWidget {
|
||||
const DisputeFormWidget({super.key, required this.disputeInfo});
|
||||
|
||||
final EarningDisputeInfo disputeInfo;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
KwDropdown<String>(
|
||||
title: 'reason'.tr(),
|
||||
hintText: 'select_reason_from_list'.tr(),
|
||||
horizontalPadding: 24,
|
||||
items: [
|
||||
for (final reason in _disputeReasonsData)
|
||||
KwDropDownItem(data: reason, title: reason),
|
||||
],
|
||||
onSelected: (reason) => disputeInfo.reason = reason,
|
||||
),
|
||||
const Gap(8),
|
||||
KwTextInput(
|
||||
title: 'additional_reasons'.tr(),
|
||||
minHeight: 114,
|
||||
controller: TextEditingController(),
|
||||
onChanged: (details) => disputeInfo.details = details,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final _disputeReasonsData = [
|
||||
'incorrect_hours'.tr(),
|
||||
'incorrect_charges'.tr(),
|
||||
'other'.tr()
|
||||
];
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/presentation/gen/assets.gen.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
|
||||
class EarningsAppBar extends StatelessWidget {
|
||||
final Widget child;
|
||||
final bool leading;
|
||||
|
||||
const EarningsAppBar({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.leading = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.loose,
|
||||
children: [
|
||||
_buildProfileBackground(context),
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: Center(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Gap(16),
|
||||
_buildAppBar(context),
|
||||
const Gap(24),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProfileBackground(BuildContext context) {
|
||||
return Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(12),
|
||||
bottomRight: Radius.circular(12),
|
||||
),
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
color: AppColors.bgColorDark,
|
||||
child: Assets.images.bg.svg(
|
||||
fit: BoxFit.fitWidth,
|
||||
alignment: Alignment.topCenter,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context) {
|
||||
return Container(
|
||||
height: 48,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (leading) ...[
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: Colors.transparent,
|
||||
alignment: Alignment.center,
|
||||
child: Assets.images.appBar.appbarLeading.svg(
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Colors.white,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
Text(
|
||||
'Earnings'.tr(),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(color: Colors.white),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
alignment: Alignment.center,
|
||||
child: Assets.images.appBar.notification.svg(
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Colors.white,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/features/earning/presentation/widget/earnings_charts_widget.dart';
|
||||
|
||||
class EarningsCharts extends StatefulWidget {
|
||||
const EarningsCharts({super.key});
|
||||
|
||||
@override
|
||||
State<EarningsCharts> createState() => _EarningsChartsState();
|
||||
}
|
||||
|
||||
class _EarningsChartsState extends State<EarningsCharts> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: KwBoxDecorations.primaryDark
|
||||
.copyWith(color: AppColors.darkBgStroke),
|
||||
height: 218,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'12.01 - 12.31.2024 period:',
|
||||
style: AppTextStyles.captionReg
|
||||
.copyWith(color: AppColors.bgColorLight),
|
||||
),
|
||||
const Gap(8),
|
||||
Text('\$1490',style: AppTextStyles.headingH3.copyWith(color: AppColors.primaryYellow),),
|
||||
const SizedBox(
|
||||
height: 150,
|
||||
child: EarningsChartsWidget(),
|
||||
),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
|
||||
class EarningsChartsWidget extends StatefulWidget {
|
||||
const EarningsChartsWidget({super.key});
|
||||
|
||||
@override
|
||||
State<EarningsChartsWidget> createState() => _EarningsChartsWidgetState();
|
||||
}
|
||||
|
||||
class _EarningsChartsWidgetState extends State<EarningsChartsWidget> {
|
||||
var touchedIndex = -1;
|
||||
var values = [300, 99, 300, 9];
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var maxValue = values.reduce((value, element) => value > element ? value : element);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top:8.0),
|
||||
child: BarChart(
|
||||
BarChartData(
|
||||
alignment: BarChartAlignment.center,
|
||||
maxY: maxValue.toDouble(),
|
||||
minY: 0,
|
||||
groupsSpace: 12,
|
||||
barTouchData: buildBarTouchData(),
|
||||
borderData: FlBorderData(
|
||||
show: true,
|
||||
border: const Border(
|
||||
bottom: BorderSide(
|
||||
color: AppColors.darkBgInactive,
|
||||
width: 1,
|
||||
),
|
||||
top: BorderSide(
|
||||
color: AppColors.darkBgInactive,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
titlesData: _buildTiles(),
|
||||
gridData: buildFlGridData(),
|
||||
barGroups: getData()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
FlGridData buildFlGridData() {
|
||||
return FlGridData(
|
||||
show: true,
|
||||
checkToShowHorizontalLine: (value) {
|
||||
return true;
|
||||
},
|
||||
getDrawingHorizontalLine: (value) => const FlLine(
|
||||
color: AppColors.darkBgInactive,
|
||||
dashArray: [4, 4],
|
||||
strokeWidth: 1,
|
||||
),
|
||||
drawVerticalLine: false,
|
||||
);
|
||||
}
|
||||
|
||||
FlTitlesData _buildTiles() {
|
||||
return FlTitlesData(
|
||||
show: true,
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 16,
|
||||
getTitlesWidget: _buildBottomTiles,
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
maxIncluded: true,
|
||||
showTitles: true,
|
||||
reservedSize: 28,
|
||||
getTitlesWidget: _buildLeftTitles,
|
||||
),
|
||||
),
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLeftTitles(double value, TitleMeta meta) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: value == 0?8.0:0),
|
||||
child: Text(
|
||||
meta.formattedValue,
|
||||
style:
|
||||
AppTextStyles.bodyTinyReg.copyWith(color: AppColors.darkBgInactive),
|
||||
),
|
||||
);
|
||||
}
|
||||
Widget _buildBottomTiles(double value, TitleMeta meta) {
|
||||
var diap = '1-8 9-15 16-23 24-30'.split(' ');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top:4.0),
|
||||
child: Text(
|
||||
diap[value.toInt()],
|
||||
style:
|
||||
AppTextStyles.bodyTinyReg.copyWith(color: AppColors.darkBgInactive),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
BarTouchData buildBarTouchData() {
|
||||
return BarTouchData(
|
||||
allowTouchBarBackDraw: true,
|
||||
touchExtraThreshold: const EdgeInsets.only(top:16),
|
||||
touchCallback: (FlTouchEvent event, barTouchResponse) {
|
||||
setState(() {
|
||||
if (!event.isInterestedForInteractions ||
|
||||
barTouchResponse == null ||
|
||||
barTouchResponse.spot == null) {
|
||||
touchedIndex = -1;
|
||||
return;
|
||||
}
|
||||
touchedIndex = barTouchResponse.spot!.touchedBarGroupIndex;
|
||||
});
|
||||
},
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
getTooltipColor: (group) => AppColors.primaryMint,
|
||||
tooltipRoundedRadius: 24,
|
||||
tooltipPadding:
|
||||
const EdgeInsets.only(left: 12, right: 12, top: 12, bottom: 8),
|
||||
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
||||
return BarTooltipItem(
|
||||
'\$${rod.toY.round()}',
|
||||
AppTextStyles.bodyMediumMed,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<BarChartGroupData> getData() {
|
||||
double width = (MediaQuery.of(context).size.width-140)/4;
|
||||
|
||||
|
||||
return List.generate(4, (i)=>
|
||||
BarChartGroupData(
|
||||
x: i,
|
||||
barRods: [
|
||||
BarChartRodData(
|
||||
toY: values[i].toDouble(),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
color: touchedIndex == i?AppColors.primaryMint:AppColors.primaryYellow,
|
||||
width: width),
|
||||
],
|
||||
),);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
|
||||
import 'package:krow/features/earning/presentation/widget/animated_slider.dart';
|
||||
|
||||
class EarningsHeader extends StatelessWidget {
|
||||
const EarningsHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: 24,
|
||||
left: 12,
|
||||
right: 12,
|
||||
),
|
||||
decoration: KwBoxDecorations.primaryDark,
|
||||
child: BlocBuilder<EarningsBloc, EarningsState>(
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'your_earnings'.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTextStyles.bodyMediumMed
|
||||
.copyWith(color: AppColors.grayWhite),
|
||||
),
|
||||
const Gap(12),
|
||||
const AnimatedSlider(),
|
||||
const Gap(24),
|
||||
Text(
|
||||
'\$${state.totalEarnings.toStringAsFixed(2)}',
|
||||
style: AppTextStyles.headingH0
|
||||
.copyWith(color: AppColors.grayWhite),
|
||||
),
|
||||
const Gap(24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: KwBoxDecorations.primaryDark.copyWith(
|
||||
color: AppColors.darkBgStroke,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildInfoRow(
|
||||
'total_worked_hours'.tr(),
|
||||
'${state.totalHours.toInt()}',
|
||||
),
|
||||
// const Gap(12),
|
||||
// _buildInfoRow(
|
||||
// 'Payout:',
|
||||
// '\$${state.totalPayout.toStringAsFixed(2)}',
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
// const Gap(24),
|
||||
// KwButton.accent(
|
||||
// label: 'Earnings History',
|
||||
// onPressed: () {
|
||||
// context.router.push(const EarningsHistoryRoute());
|
||||
// },
|
||||
// )
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Row _buildInfoRow(String title, String? value) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style:
|
||||
AppTextStyles.bodyMediumMed.copyWith(color: AppColors.grayWhite),
|
||||
),
|
||||
Text(
|
||||
value ?? '',
|
||||
style:
|
||||
AppTextStyles.bodyMediumMed.copyWith(color: AppColors.grayWhite),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/presentation/gen/assets.gen.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_popup_menu.dart';
|
||||
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
|
||||
import 'package:krow/features/earning/presentation/widget/earnings_charts_card.dart';
|
||||
|
||||
class EarningsHistoryHeader extends StatelessWidget {
|
||||
const EarningsHistoryHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(bottom: 24, left: 12, right: 12),
|
||||
decoration: KwBoxDecorations.primaryDark,
|
||||
child: BlocBuilder<EarningsBloc, EarningsState>(
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildPeriodRow(),
|
||||
const Gap(12),
|
||||
const EarningsCharts(),
|
||||
const Gap(8),
|
||||
_buildMinMaxRow(
|
||||
state.earnings.minEarningInPeriod,
|
||||
state.earnings.maxEarningInPeriod,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
_buildMinMaxRow(int min, int max) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration:
|
||||
KwBoxDecorations.primaryDark.copyWith(color: AppColors.darkBgStroke),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildInfoRow('max_earning'.tr(), '\$$max'),
|
||||
const Gap(12),
|
||||
_buildInfoRow('min_earning'.tr(), '\$$min'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Row _buildInfoRow(String title, String? value) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style:
|
||||
AppTextStyles.bodyMediumMed.copyWith(color: AppColors.grayWhite),
|
||||
),
|
||||
Text(
|
||||
value ?? '',
|
||||
style:
|
||||
AppTextStyles.bodyMediumMed.copyWith(color: AppColors.grayWhite),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Row _buildPeriodRow() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'earnings_history'.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
AppTextStyles.bodyMediumMed.copyWith(color: AppColors.grayWhite),
|
||||
),
|
||||
KwPopupMenu(
|
||||
customButtonBuilder: (context, isOpen) {
|
||||
return Row(
|
||||
children: [
|
||||
Assets.images.icons.calendar.svg(
|
||||
width: 12,
|
||||
height: 12,
|
||||
colorFilter: const ColorFilter.mode(
|
||||
AppColors.grayWhite, BlendMode.srcIn)),
|
||||
const Gap(2),
|
||||
Text(
|
||||
'period'.tr(),
|
||||
style: AppTextStyles.bodyMediumMed
|
||||
.copyWith(color: AppColors.grayWhite),
|
||||
),
|
||||
const Gap(12),
|
||||
AnimatedRotation(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
turns: isOpen ? -0.5 : 0,
|
||||
child: Assets.images.icons.caretDown.svg(
|
||||
width: 16,
|
||||
height: 16,
|
||||
colorFilter: const ColorFilter.mode(
|
||||
AppColors.darkBgInactive, BlendMode.srcIn),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
menuItems: [
|
||||
KwPopupMenuItem(title: 'week'.tr(), onTap: () {}),
|
||||
KwPopupMenuItem(title: 'month'.tr(), onTap: () {}),
|
||||
KwPopupMenuItem(title: 'range'.tr(), onTap: () {}),
|
||||
])
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/core/presentation/gen/assets.gen.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/dialogs/kw_dialog.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
|
||||
import 'package:krow/features/earning/domain/bloc/earnings_bloc.dart';
|
||||
import 'package:krow/features/earning/domain/entities/earning_dispute_info.dart';
|
||||
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
|
||||
import 'package:krow/features/earning/presentation/widget/dispute_form_widget.dart';
|
||||
|
||||
class EarningActionWidget extends StatefulWidget {
|
||||
const EarningActionWidget({
|
||||
super.key,
|
||||
required this.earningShiftEntity,
|
||||
});
|
||||
|
||||
final EarningShiftEntity earningShiftEntity;
|
||||
|
||||
@override
|
||||
State<EarningActionWidget> createState() => _EarningActionWidgetState();
|
||||
}
|
||||
|
||||
class _EarningActionWidgetState extends State<EarningActionWidget> {
|
||||
CrossFadeState _crossFadeState = CrossFadeState.showFirst;
|
||||
|
||||
Future<void> _onConfirmPaymentPressed() async {
|
||||
final bloc = context.read<EarningsBloc>();
|
||||
|
||||
await KwDialog.show<void>(
|
||||
context: context,
|
||||
icon: Assets.images.icons.moneyRecive,
|
||||
state: KwDialogState.positive,
|
||||
title: 'your_earnings_are_in'.tr(),
|
||||
message: '${'you_earned_for_shift'.tr(args: [
|
||||
widget.earningShiftEntity.earned.toString(),
|
||||
widget.earningShiftEntity.eventName,
|
||||
DateFormat('d MMM yyyy', context.locale.languageCode).format(
|
||||
widget.earningShiftEntity.eventDate,
|
||||
)
|
||||
])}\n\n${'total_earnings_added'.tr(args: [
|
||||
widget.earningShiftEntity.earned.toString()
|
||||
])}',
|
||||
primaryButtonLabel: 'confirm_earning'.tr(),
|
||||
secondaryButtonLabel: 'dispute_contact_support'.tr(),
|
||||
onPrimaryButtonPressed: (dialogContext) {
|
||||
Navigator.pop(dialogContext);
|
||||
|
||||
bloc.add(ConfirmStaffEarning(widget.earningShiftEntity.id));
|
||||
setState(() => _crossFadeState = CrossFadeState.showSecond);
|
||||
},
|
||||
onSecondaryButtonPressed: (dialogContext) {
|
||||
Navigator.pop(dialogContext);
|
||||
|
||||
_onDisputePaymentPressed();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onDisputePaymentPressed() async {
|
||||
final bloc = context.read<EarningsBloc>();
|
||||
final disputeInfo = EarningDisputeInfo();
|
||||
|
||||
await KwDialog.show<void>(
|
||||
context: context,
|
||||
icon: Assets.images.icons.moneyRecive,
|
||||
state: KwDialogState.negative,
|
||||
title: 'dispute_earnings'.tr(),
|
||||
message: 'dispute_message'.tr(),
|
||||
primaryButtonLabel: 'Submit Dispute',
|
||||
secondaryButtonLabel: 'cancel'.tr(),
|
||||
child: DisputeFormWidget(disputeInfo: disputeInfo),
|
||||
onPrimaryButtonPressed: (dialogContext) {
|
||||
Navigator.pop(dialogContext);
|
||||
|
||||
bloc.add(
|
||||
DisputeStaffEarning(
|
||||
earningId: widget.earningShiftEntity.id,
|
||||
reason: disputeInfo.reason,
|
||||
details: disputeInfo.details,
|
||||
),
|
||||
);
|
||||
setState(() => _crossFadeState = CrossFadeState.showSecond);
|
||||
},
|
||||
onSecondaryButtonPressed: (dialogContext) {
|
||||
Navigator.pop(dialogContext);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedCrossFade(
|
||||
duration: Durations.medium4,
|
||||
crossFadeState: _crossFadeState,
|
||||
secondChild: const SizedBox.shrink(),
|
||||
firstChild: Padding(
|
||||
padding: const EdgeInsets.only(left: 20, right: 20, top: 24),
|
||||
child: KwButton.outlinedPrimary(
|
||||
label: 'confirm'.tr(),
|
||||
onPressed: _onConfirmPaymentPressed,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
|
||||
|
||||
class EarningCardCompactWidget extends StatelessWidget {
|
||||
final EarningShiftEntity earningEntity;
|
||||
|
||||
const EarningCardCompactWidget({
|
||||
super.key,
|
||||
required this.earningEntity,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: KwBoxDecorations.primaryLight12,
|
||||
height: 72,
|
||||
margin: const EdgeInsets.only(bottom: 12, left: 16, right: 16),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildContent(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: earningEntity.businessImageUrl,
|
||||
fit: BoxFit.cover,
|
||||
height: 48,
|
||||
width: 48,
|
||||
)),
|
||||
const Gap(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
const Gap(5),
|
||||
Text(
|
||||
earningEntity.skillName,
|
||||
style: AppTextStyles.bodyMediumMed,
|
||||
),
|
||||
const Gap(4),
|
||||
Text(
|
||||
earningEntity.businessName,
|
||||
style: AppTextStyles.bodySmallReg
|
||||
.copyWith(color: AppColors.blackGray),
|
||||
),
|
||||
const Gap(5),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 5),
|
||||
child: Text(
|
||||
'\$${earningEntity.earned}',
|
||||
style: AppTextStyles.bodyMediumMed,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
|
||||
import 'package:krow/core/presentation/widgets/shift_payment_step_widget.dart';
|
||||
import 'package:krow/core/presentation/widgets/shift_total_time_spend_widget.dart';
|
||||
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
|
||||
import 'package:krow/features/earning/presentation/widget/list_item/earning_action_widget.dart';
|
||||
import 'package:krow/features/earning/presentation/widget/list_item/shift_item_header_widget.dart';
|
||||
|
||||
class EarningCardWidget extends StatelessWidget {
|
||||
const EarningCardWidget({
|
||||
super.key,
|
||||
required this.earningEntity,
|
||||
});
|
||||
|
||||
final EarningShiftEntity earningEntity;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: KwBoxDecorations.primaryLight12,
|
||||
padding: const EdgeInsets.only(top: 24, bottom: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShiftItemHeaderWidget(earningEntity),
|
||||
ShiftTotalTimeSpendWidget(
|
||||
startTime: earningEntity.clockIn!,
|
||||
endTime: earningEntity.clockOut!,
|
||||
totalBreakTime: earningEntity.totalBreakTime ?? 0,
|
||||
),
|
||||
ShiftPaymentStepWidget(
|
||||
currentIndex: earningEntity.paymentProgressStep,
|
||||
),
|
||||
if (earningEntity.status == EarningStatus.confirmedByAdmin)
|
||||
EarningActionWidget(earningShiftEntity: earningEntity),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
|
||||
import 'package:krow/features/earning/presentation/widget/list_item/shift_status_label_widget.dart';
|
||||
|
||||
class ShiftItemHeaderWidget extends StatelessWidget {
|
||||
final EarningShiftEntity viewModel;
|
||||
|
||||
const ShiftItemHeaderWidget(
|
||||
this.viewModel, {
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: viewModel.businessImageUrl,
|
||||
fit: BoxFit.cover,
|
||||
height: 48,
|
||||
width: 48,
|
||||
),
|
||||
),
|
||||
EarnedShiftStatusLabelWidget(viewModel),
|
||||
],
|
||||
),
|
||||
const Gap(12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
viewModel.skillName,
|
||||
style: AppTextStyles.bodyMediumMed,
|
||||
),
|
||||
),
|
||||
const Gap(24),
|
||||
Text(
|
||||
'\$${viewModel.earned}',
|
||||
style: AppTextStyles.bodyMediumMed,
|
||||
)
|
||||
],
|
||||
),
|
||||
const Gap(4),
|
||||
Text(
|
||||
viewModel.businessName,
|
||||
style: AppTextStyles.bodySmallReg.copyWith(
|
||||
color: AppColors.blackGray,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/features/earning/domain/entities/earning_shift_entity.dart';
|
||||
|
||||
class EarnedShiftStatusLabelWidget extends StatefulWidget {
|
||||
final EarningShiftEntity viewModel;
|
||||
|
||||
const EarnedShiftStatusLabelWidget(this.viewModel, {super.key});
|
||||
|
||||
@override
|
||||
State<EarnedShiftStatusLabelWidget> createState() =>
|
||||
_EarnedShiftStatusLabelWidgetState();
|
||||
}
|
||||
|
||||
class _EarnedShiftStatusLabelWidgetState
|
||||
extends State<EarnedShiftStatusLabelWidget> {
|
||||
Color getColor() {
|
||||
return AppColors.bgColorDark;
|
||||
// switch (widget.viewModel.status) {
|
||||
// case EventShiftRoleStaffStatus.assigned:
|
||||
// return AppColors.primaryBlue;
|
||||
// case EventShiftRoleStaffStatus.confirmed:
|
||||
// return AppColors.statusWarning;
|
||||
// case EventShiftRoleStaffStatus.ongoing:
|
||||
// return AppColors.statusSuccess;
|
||||
// case EventShiftRoleStaffStatus.completed:
|
||||
// return AppColors.bgColorDark;
|
||||
// case EventShiftRoleStaffStatus.canceledByAdmin:
|
||||
// case EventShiftRoleStaffStatus.canceledByBusiness:
|
||||
// case EventShiftRoleStaffStatus.canceledByStaff:
|
||||
// case EventShiftRoleStaffStatus.requestedReplace:
|
||||
// return AppColors.statusError;
|
||||
// }
|
||||
}
|
||||
|
||||
String getText() {
|
||||
return 'completed'.tr();
|
||||
// switch (widget.viewModel.status) {
|
||||
// case EventShiftRoleStaffStatus.assigned:
|
||||
// return _getAssignedAgo();
|
||||
// case EventShiftRoleStaffStatus.confirmed:
|
||||
// return _getStartIn();
|
||||
// case EventShiftRoleStaffStatus.ongoing:
|
||||
// return 'Ongoing';
|
||||
// case EventShiftRoleStaffStatus.completed:
|
||||
// return 'Completed';
|
||||
// case EventShiftRoleStaffStatus.canceledByAdmin:
|
||||
// case EventShiftRoleStaffStatus.canceledByBusiness:
|
||||
// case EventShiftRoleStaffStatus.canceledByStaff:
|
||||
// case EventShiftRoleStaffStatus.requestedReplace:
|
||||
// return 'Canceled';
|
||||
// }
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 24,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: getColor(),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
getText(),
|
||||
style: AppTextStyles.bodySmallMed
|
||||
.copyWith(color: AppColors.grayWhite, height: 0.7),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/application/di/injectable.dart';
|
||||
import 'package:krow/core/application/routing/routes.gr.dart';
|
||||
import 'package:krow/core/presentation/gen/assets.gen.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/core/sevices/app_update_service.dart';
|
||||
|
||||
@RoutePage()
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
final List<PageRouteInfo> _routes = const [
|
||||
EarningsFlowRoute(),
|
||||
ShiftsFlowRoute(),
|
||||
ProfileMainFlowRoute(),
|
||||
];
|
||||
|
||||
bool _shouldHideBottomNavBar(BuildContext nestedContext) {
|
||||
final currentPath = context.router.currentPath;
|
||||
return _allowBottomNavBarRoutes
|
||||
.any((route) => currentPath == route.toString());
|
||||
}
|
||||
|
||||
final List<String> _allowBottomNavBarRoutes = [
|
||||
'/home/shifts/list',
|
||||
'/home/earnings/list',
|
||||
'/home/profile/menu'
|
||||
];
|
||||
|
||||
getMediaQueryData(BuildContext context) {
|
||||
if (!_shouldHideBottomNavBar(context)) {
|
||||
return MediaQuery.of(context);
|
||||
} else {
|
||||
//76 - navigation height, 22 - bottom padding
|
||||
var newBottomPadding = MediaQuery.of(context).padding.bottom + 76 + 22;
|
||||
var newPadding =
|
||||
MediaQuery.of(context).padding.copyWith(bottom: newBottomPadding);
|
||||
return MediaQuery.of(context).copyWith(padding: newPadding);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
// getIt<BackgroundService>().initializeService();
|
||||
super.initState();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((call) {
|
||||
getIt<AppUpdateService>().checkForUpdate(context);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
return Scaffold(
|
||||
body: AutoTabsRouter(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
routes: _routes,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
children: [
|
||||
MediaQuery(
|
||||
data: getMediaQueryData(context),
|
||||
child: child,
|
||||
),
|
||||
if (_shouldHideBottomNavBar(context)) _bottomNavBar(context),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bottomNavBar(context) {
|
||||
final tabsRouter = AutoTabsRouter.of(context);
|
||||
return Positioned(
|
||||
bottom: 22,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
height: 76,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(38),
|
||||
color: AppColors.bgColorDark,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_navBarItem('Earnings',
|
||||
Assets.images.icons.navigation.emptyWallet, 0, tabsRouter),
|
||||
_navBarItem('Shifts',
|
||||
Assets.images.icons.navigation.clipboardText, 1, tabsRouter),
|
||||
_navBarItem('Profile', Assets.images.icons.navigation.profile, 2,
|
||||
tabsRouter),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _navBarItem(
|
||||
String text,
|
||||
SvgGenImage icon,
|
||||
int index,
|
||||
tabsRouter,
|
||||
) {
|
||||
var color = tabsRouter.activeIndex == index
|
||||
? AppColors.navBarActive
|
||||
: AppColors.navBarDisabled;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (index == tabsRouter.activeIndex && index == 0) {
|
||||
(tabsRouter.innerRouterOf(EarningsFlowRoute.name)!).maybePop();
|
||||
}
|
||||
if (index == tabsRouter.activeIndex && index == 1) {
|
||||
(tabsRouter.innerRouterOf(ShiftsFlowRoute.name)!).maybePop();
|
||||
}
|
||||
if (index == tabsRouter.activeIndex && index == 2) {
|
||||
(tabsRouter.innerRouterOf(ProfileMainFlowRoute.name)!).maybePop();
|
||||
}
|
||||
tabsRouter.setActiveIndex(index);
|
||||
},
|
||||
child: Container(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
icon.svg(
|
||||
colorFilter: ColorFilter.mode(
|
||||
color,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
text.tr(),
|
||||
style: (tabsRouter.activeIndex == index
|
||||
? AppTextStyles.bodyTinyMed
|
||||
: AppTextStyles.bodyTinyReg)
|
||||
.copyWith(color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:krow/core/application/clients/api/api_client.dart';
|
||||
import 'package:krow/core/data/models/staff/full_address_model.dart';
|
||||
import 'package:krow/features/profile/address/data/gql.dart';
|
||||
|
||||
@injectable
|
||||
class AddressApiProvider {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
AddressApiProvider(this._apiClient);
|
||||
|
||||
Stream<FullAddress?> getStaffAddress() async* {
|
||||
await for (var response
|
||||
in _apiClient.queryWithCache(schema: staffAddress)) {
|
||||
if (response == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (response.hasException) {
|
||||
throw Exception(response.exception.toString());
|
||||
}
|
||||
|
||||
final address =
|
||||
FullAddress.fromJson(response.data?['me']?['full_address'] ?? {});
|
||||
yield address;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> putAddress(FullAddress address) async {
|
||||
final Map<String, dynamic> variables = {
|
||||
'input': address.toJson(),
|
||||
};
|
||||
|
||||
var result =
|
||||
await _apiClient.mutate(schema: saveFullAddress, body: variables);
|
||||
|
||||
if (result.hasException) {
|
||||
throw Exception(result.exception.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import 'package:krow/core/data/models/staff/full_address_model.dart';
|
||||
|
||||
abstract class AddressRepository {
|
||||
Stream<FullAddress?> getStaffAddress();
|
||||
|
||||
Future<void> putAddress(FullAddress address);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
const String staffAddress = r'''
|
||||
query staffAddress {
|
||||
me {
|
||||
id
|
||||
full_address {
|
||||
street_number
|
||||
zip_code
|
||||
latitude
|
||||
longitude
|
||||
formatted_address
|
||||
street
|
||||
region
|
||||
city
|
||||
country
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
const String saveFullAddress = r'''
|
||||
mutation saveFullAddress($input: AddressInput!) {
|
||||
update_staff_address(input: $input) {
|
||||
|
||||
}
|
||||
}
|
||||
''';
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:krow/core/data/models/staff/full_address_model.dart';
|
||||
import 'package:krow/features/profile/address/data/address_api_provider.dart';
|
||||
import 'package:krow/features/profile/address/data/address_repository.dart';
|
||||
|
||||
@Singleton(as: AddressRepository)
|
||||
class AddressRepositoryImpl implements AddressRepository {
|
||||
final AddressApiProvider _apiProvider;
|
||||
|
||||
AddressRepositoryImpl({required AddressApiProvider apiProvider})
|
||||
: _apiProvider = apiProvider;
|
||||
|
||||
@override
|
||||
Stream<FullAddress?> getStaffAddress() {
|
||||
return _apiProvider.getStaffAddress();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> putAddress(FullAddress address) {
|
||||
return _apiProvider.putAddress(address);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/core/application/di/injectable.dart';
|
||||
import 'package:krow/core/data/models/staff/full_address_model.dart';
|
||||
import 'package:krow/core/data/enums/state_status.dart';
|
||||
import 'package:krow/features/profile/address/data/address_repository.dart';
|
||||
import 'package:krow/features/profile/address/domain/google_places_service.dart';
|
||||
|
||||
|
||||
part 'address_event.dart';
|
||||
part 'address_state.dart';
|
||||
|
||||
class AddressBloc extends Bloc<AddressEvent, AddressState> {
|
||||
AddressBloc() : super(const AddressState()) {
|
||||
on<InitializeAddressEvent>(_onInitialize);
|
||||
on<SubmitAddressEvent>(_onSubmit);
|
||||
on<AddressQueryChangedEvent>(_onQueryChanged);
|
||||
on<AddressSelectEvent>(_onSelect);
|
||||
}
|
||||
|
||||
void _onInitialize(
|
||||
InitializeAddressEvent event, Emitter<AddressState> emit) async {
|
||||
emit(state.copyWith(status: StateStatus.loading));
|
||||
await for (var address in getIt<AddressRepository>().getStaffAddress()) {
|
||||
emit(state.copyWith(
|
||||
fullAddress: address,
|
||||
status: StateStatus.idle,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _onQueryChanged(
|
||||
AddressQueryChangedEvent event, Emitter<AddressState> emit) async {
|
||||
try {
|
||||
final googlePlacesService = GooglePlacesService();
|
||||
final suggestions =
|
||||
await googlePlacesService.fetchSuggestions(event.query);
|
||||
emit(state.copyWith(suggestions: suggestions));
|
||||
} catch (e) {
|
||||
if (kDebugMode) print(e);
|
||||
}
|
||||
}
|
||||
|
||||
void _onSelect(AddressSelectEvent event, Emitter<AddressState> emit) async {
|
||||
final googlePlacesService = GooglePlacesService();
|
||||
final fullAddress =
|
||||
await googlePlacesService.getPlaceDetails(event.place.placeId);
|
||||
FullAddress address = FullAddress.fromGoogle(fullAddress);
|
||||
emit(state.copyWith(suggestions: [], fullAddress: address));
|
||||
}
|
||||
|
||||
void _onSubmit(SubmitAddressEvent event, Emitter<AddressState> emit) async {
|
||||
emit(state.copyWith(status: StateStatus.loading));
|
||||
try {
|
||||
await getIt<AddressRepository>().putAddress(state.fullAddress!);
|
||||
emit(state.copyWith(status: StateStatus.success));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(status: StateStatus.error));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
part of 'address_bloc.dart';
|
||||
|
||||
@immutable
|
||||
sealed class AddressEvent {}
|
||||
|
||||
class InitializeAddressEvent extends AddressEvent {
|
||||
InitializeAddressEvent();
|
||||
}
|
||||
|
||||
class AddressQueryChangedEvent extends AddressEvent {
|
||||
final String query;
|
||||
|
||||
AddressQueryChangedEvent(this.query);
|
||||
}
|
||||
|
||||
class SubmitAddressEvent extends AddressEvent {
|
||||
SubmitAddressEvent();
|
||||
}
|
||||
|
||||
class AddressSelectEvent extends AddressEvent {
|
||||
final MapPlace place;
|
||||
|
||||
AddressSelectEvent(this.place);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
part of 'address_bloc.dart';
|
||||
|
||||
@immutable
|
||||
class AddressState {
|
||||
final StateStatus status;
|
||||
final FullAddress? fullAddress;
|
||||
final List<MapPlace> suggestions;
|
||||
|
||||
const AddressState({
|
||||
this.status = StateStatus.idle,
|
||||
this.fullAddress,
|
||||
this.suggestions = const [],
|
||||
});
|
||||
|
||||
AddressState copyWith({
|
||||
StateStatus? status,
|
||||
FullAddress? fullAddress,
|
||||
List<MapPlace>? suggestions,
|
||||
}) {
|
||||
return AddressState(
|
||||
status: status ?? this.status,
|
||||
suggestions: suggestions ?? this.suggestions,
|
||||
fullAddress: fullAddress ?? this.fullAddress,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
@singleton
|
||||
class GooglePlacesService {
|
||||
|
||||
Future<List<MapPlace>> fetchSuggestions(String query) async {
|
||||
final String apiKey = dotenv.env['GOOGLE_MAP']!;
|
||||
|
||||
const String baseUrl =
|
||||
'https://maps.googleapis.com/maps/api/place/autocomplete/json';
|
||||
final Uri uri =
|
||||
Uri.parse('$baseUrl?input=$query&key=$apiKey&types=geocode');
|
||||
|
||||
final response = await http.get(uri);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
final List<dynamic> predictions = data['predictions'];
|
||||
|
||||
return predictions.map((prediction) {
|
||||
return MapPlace.fromJson(prediction);
|
||||
}).toList();
|
||||
} else {
|
||||
throw Exception('Failed to fetch place suggestions');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getPlaceDetails(String placeId) async {
|
||||
final String apiKey = dotenv.env['GOOGLE_MAP']!;
|
||||
final String url =
|
||||
'https://maps.googleapis.com/maps/api/place/details/json?place_id=$placeId&key=$apiKey';
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
final result = data['result'];
|
||||
final location = result['geometry']['location'];
|
||||
|
||||
Map<String, dynamic> addressDetails = {
|
||||
'lat': location['lat'], // Latitude
|
||||
'lng': location['lng'], // Longitude
|
||||
'formatted_address': result['formatted_address'], // Full Address
|
||||
};
|
||||
|
||||
for (var component in result['address_components']) {
|
||||
List types = component['types'];
|
||||
if (types.contains('street_number')) {
|
||||
addressDetails['street_number'] = component['long_name'];
|
||||
}
|
||||
if (types.contains('route')) {
|
||||
addressDetails['street'] = component['long_name'];
|
||||
}
|
||||
if (types.contains('locality')) {
|
||||
addressDetails['city'] = component['long_name'];
|
||||
}
|
||||
if (types.contains('administrative_area_level_1')) {
|
||||
addressDetails['state'] = component['long_name'];
|
||||
}
|
||||
if (types.contains('country')) {
|
||||
addressDetails['country'] = component['long_name'];
|
||||
}
|
||||
if (types.contains('postal_code')) {
|
||||
addressDetails['postal_code'] = component['long_name'];
|
||||
}
|
||||
}
|
||||
|
||||
return addressDetails;
|
||||
} else {
|
||||
throw Exception('Failed to fetch place details');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MapPlace {
|
||||
final String description;
|
||||
final String placeId;
|
||||
|
||||
MapPlace({required this.description, required this.placeId});
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
'description': description,
|
||||
'place_id': placeId,
|
||||
};
|
||||
}
|
||||
|
||||
factory MapPlace.fromJson(Map<String, dynamic> json) {
|
||||
return MapPlace(
|
||||
description: json['description'],
|
||||
placeId: json['place_id'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/application/routing/routes.gr.dart';
|
||||
import 'package:krow/core/data/enums/state_status.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/core/presentation/widgets/scroll_layout_helper.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_suggestion_input.dart';
|
||||
import 'package:krow/features/profile/address/domain/bloc/address_bloc.dart';
|
||||
import 'package:modal_progress_hud_nsn/modal_progress_hud_nsn.dart';
|
||||
|
||||
@RoutePage()
|
||||
class AddressScreen extends StatefulWidget implements AutoRouteWrapper {
|
||||
final bool isInEditMode;
|
||||
|
||||
const AddressScreen({super.key, this.isInEditMode = true});
|
||||
|
||||
@override
|
||||
State<AddressScreen> createState() => _AddressScreenState();
|
||||
|
||||
@override
|
||||
Widget wrappedRoute(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => AddressBloc()..add(InitializeAddressEvent()),
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddressScreenState extends State<AddressScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: KwAppBar(
|
||||
showNotification: widget.isInEditMode,
|
||||
titleText: 'location_and_availability'.tr(),
|
||||
),
|
||||
body: BlocConsumer<AddressBloc, AddressState>(
|
||||
listenWhen: (previous, current) => previous.status != current.status,
|
||||
listener: (context, state) {
|
||||
if (state.status == StateStatus.success) {
|
||||
if (widget.isInEditMode) {
|
||||
Navigator.pop(context);
|
||||
} else {
|
||||
context.router.push(
|
||||
WorkingAreaRoute(),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return ModalProgressHUD(
|
||||
inAsyncCall: state.status == StateStatus.loading,
|
||||
child: ScrollLayoutHelper(
|
||||
padding: const EdgeInsets.all(16),
|
||||
upperWidget: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!widget.isInEditMode) ...[
|
||||
const Gap(4),
|
||||
Text(
|
||||
'what_is_your_address'.tr(),
|
||||
style: AppTextStyles.headingH1,
|
||||
),
|
||||
Text(
|
||||
'let_us_know_your_home_base'.tr(),
|
||||
style: AppTextStyles.bodyMediumReg
|
||||
.copyWith(color: AppColors.blackGray),
|
||||
),
|
||||
const Gap(24),
|
||||
]
|
||||
else
|
||||
const Gap(8),
|
||||
KwSuggestionInput(
|
||||
title: 'address'.tr(),
|
||||
hintText: 'select_address'.tr(),
|
||||
horizontalPadding: 16,
|
||||
items: state.suggestions,
|
||||
onQueryChanged: (query) {
|
||||
context
|
||||
.read<AddressBloc>()
|
||||
.add(AddressQueryChangedEvent(query));
|
||||
},
|
||||
itemToStringBuilder: (item) => item.description,
|
||||
onSelected: (item) {
|
||||
context.read<AddressBloc>().add(AddressSelectEvent(item));
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: KwBoxDecorations.primaryLight12,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
..._textBlock('country'.tr(), state.fullAddress?.country),
|
||||
..._textBlock('state'.tr(), state.fullAddress?.region),
|
||||
..._textBlock('city'.tr(), state.fullAddress?.city),
|
||||
..._textBlock('apt_suite_building'.tr(),
|
||||
state.fullAddress?.streetNumber),
|
||||
..._textBlock(
|
||||
'street_address'.tr(), state.fullAddress?.street),
|
||||
..._textBlock('zip_code'.tr(), state.fullAddress?.zipCode,
|
||||
hasNext: false),
|
||||
]),
|
||||
)
|
||||
],
|
||||
),
|
||||
lowerWidget: KwButton.primary(
|
||||
disabled: state.fullAddress == null,
|
||||
label: widget.isInEditMode
|
||||
? 'save_changes'.tr()
|
||||
: 'save_and_continue'.tr(),
|
||||
onPressed: () {
|
||||
if (widget.isInEditMode) {
|
||||
context.read<AddressBloc>().add(SubmitAddressEvent());
|
||||
} else {
|
||||
context.router.push(WorkingAreaRoute(isInEditMode: false));
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _textBlock(String key, String? value, {bool hasNext = true}) {
|
||||
return [
|
||||
...[
|
||||
Text(
|
||||
key,
|
||||
style: AppTextStyles.captionReg.copyWith(color: AppColors.blackGray),
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
value ?? '',
|
||||
style: AppTextStyles.bodyMediumMed,
|
||||
),
|
||||
hasNext ? const Gap(24) : const Gap(0),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:krow/core/application/clients/api/api_client.dart';
|
||||
import 'package:krow/core/data/models/staff/bank_acc.dart';
|
||||
import 'package:krow/features/profile/bank_account/data/gql.dart';
|
||||
|
||||
@injectable
|
||||
class BankAccountApiProvider {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
BankAccountApiProvider(this._apiClient);
|
||||
|
||||
Stream<BankAcc?> getStaffBankAcc() async* {
|
||||
await for (var response
|
||||
in _apiClient.queryWithCache(schema: staffBankAccount)) {
|
||||
if (response == null || response.data == null) {
|
||||
continue;
|
||||
}
|
||||
if (response.hasException) {
|
||||
throw Exception(response.exception.toString());
|
||||
}
|
||||
final bankAcc =
|
||||
BankAcc.fromJson(response.data?['me']?['bank_account'] ?? {});
|
||||
yield bankAcc;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> putBunkAccount(BankAcc bankAcc) async {
|
||||
final Map<String, dynamic> variables = {
|
||||
'input': bankAcc.toJson(),
|
||||
};
|
||||
|
||||
var result =
|
||||
await _apiClient.mutate(schema: updateBankAccount, body: variables);
|
||||
|
||||
if (result.hasException) {
|
||||
throw Exception(result.exception.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import 'package:krow/core/data/models/staff/bank_acc.dart';
|
||||
|
||||
abstract class BankAccountRepository {
|
||||
Stream<BankAcc?> getStaffBankAcc();
|
||||
|
||||
Future<void> putBankAcc(BankAcc address);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
const String staffBankAccount = r'''
|
||||
query staffAddress {
|
||||
me {
|
||||
id
|
||||
bank_account {
|
||||
id
|
||||
holder_name
|
||||
bank_name
|
||||
number
|
||||
routing_number
|
||||
country
|
||||
state
|
||||
city
|
||||
street
|
||||
building
|
||||
zip
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
const String updateBankAccount = r'''
|
||||
mutation updateBankAccount($input: UpdateStaffBankAccountInput!) {
|
||||
update_staff_bank_account(input: $input) {
|
||||
|
||||
}
|
||||
}
|
||||
''';
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:krow/core/data/models/staff/bank_acc.dart';
|
||||
import 'package:krow/features/profile/bank_account/data/bank_account_api_provider.dart';
|
||||
import 'package:krow/features/profile/bank_account/data/bank_account_repository.dart';
|
||||
|
||||
@Singleton(as: BankAccountRepository)
|
||||
class BankAccountRepositoryImpl implements BankAccountRepository {
|
||||
final BankAccountApiProvider _apiProvider;
|
||||
|
||||
BankAccountRepositoryImpl({required BankAccountApiProvider apiProvider})
|
||||
: _apiProvider = apiProvider;
|
||||
|
||||
@override
|
||||
Stream<BankAcc?> getStaffBankAcc() {
|
||||
return _apiProvider.getStaffBankAcc();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> putBankAcc(BankAcc acc) {
|
||||
return _apiProvider.putBunkAccount(acc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/core/application/di/injectable.dart';
|
||||
import 'package:krow/core/data/models/staff/bank_acc.dart';
|
||||
import 'package:krow/features/profile/bank_account/data/bank_account_repository.dart';
|
||||
|
||||
part 'bank_account_event.dart';
|
||||
part 'bank_account_state.dart';
|
||||
|
||||
class BankAccountBloc extends Bloc<BankAccountEvent, BankAccountState> {
|
||||
BankAccountBloc() : super(const BankAccountState()) {
|
||||
on<BankAccountEventInit>(_onInit);
|
||||
on<BankAccountEventUpdate>(_onSubmit);
|
||||
}
|
||||
|
||||
FutureOr<void> _onInit(event, emit) async {
|
||||
await for (var account
|
||||
in getIt<BankAccountRepository>().getStaffBankAcc()) {
|
||||
emit(state.copyWith(bankAcc: account));
|
||||
}
|
||||
}
|
||||
|
||||
FutureOr<void> _onSubmit(BankAccountEventUpdate event, emit) async {
|
||||
emit(state.copyWith(inLoading: true));
|
||||
var newBankAcc = BankAcc.fromJson(event.bankAcc);
|
||||
await getIt<BankAccountRepository>().putBankAcc(newBankAcc);
|
||||
emit(state.copyWith(success: true, bankAcc: newBankAcc));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
part of 'bank_account_bloc.dart';
|
||||
|
||||
@immutable
|
||||
sealed class BankAccountEvent {}
|
||||
|
||||
class BankAccountEventInit extends BankAccountEvent {}
|
||||
|
||||
class BankAccountEventUpdate extends BankAccountEvent {
|
||||
final Map<String, String> bankAcc;
|
||||
|
||||
BankAccountEventUpdate(this.bankAcc);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
part of 'bank_account_bloc.dart';
|
||||
|
||||
@immutable
|
||||
class BankAccountState {
|
||||
final BankAcc? bankAcc;
|
||||
final bool inLoading;
|
||||
|
||||
final bool success;
|
||||
|
||||
const BankAccountState(
|
||||
{this.inLoading = false, this.success = false, this.bankAcc});
|
||||
|
||||
BankAccountState copyWith({
|
||||
BankAcc? bankAcc,
|
||||
bool? inLoading,
|
||||
bool? success,
|
||||
}) {
|
||||
return BankAccountState(
|
||||
inLoading: inLoading ?? false,
|
||||
success: success ?? false,
|
||||
bankAcc: bankAcc ?? this.bankAcc,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, BankAccField> mapFromBankAccount() {
|
||||
return {
|
||||
'holder_name': BankAccField(
|
||||
title: 'account_holder_name'.tr(),
|
||||
value: bankAcc?.holderName,
|
||||
),
|
||||
'bank_name': BankAccField(
|
||||
title: 'bank_name'.tr(),
|
||||
value: bankAcc?.bankName,
|
||||
),
|
||||
'number': BankAccField(
|
||||
title: 'account_number'.tr(),
|
||||
value: bankAcc?.number,
|
||||
),
|
||||
'routing_number': BankAccField(
|
||||
title: 'routing_number_us'.tr(),
|
||||
value: bankAcc?.routingNumber,
|
||||
optional: true),
|
||||
'country': BankAccField(
|
||||
title: 'country'.tr(),
|
||||
value: bankAcc?.country,
|
||||
),
|
||||
'state': BankAccField(
|
||||
title: 'state'.tr(),
|
||||
value: bankAcc?.state,
|
||||
),
|
||||
'city': BankAccField(
|
||||
title: 'city'.tr(),
|
||||
value: bankAcc?.city,
|
||||
),
|
||||
'street': BankAccField(
|
||||
title: 'street_address'.tr(),
|
||||
value: bankAcc?.street,
|
||||
),
|
||||
'building': BankAccField(
|
||||
title: 'apt_suite_building'.tr(),
|
||||
value: bankAcc?.building,
|
||||
optional: true),
|
||||
'zip': BankAccField(
|
||||
title: 'zip_code'.tr(),
|
||||
value: bankAcc?.zip,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class BankAccField {
|
||||
String title;
|
||||
String? value;
|
||||
bool optional;
|
||||
|
||||
BankAccField({required this.title, this.value = '', this.optional = false});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/features/profile/bank_account/domain/bloc/bank_account_bloc.dart';
|
||||
|
||||
@RoutePage()
|
||||
class BankAccountFlowScreen extends StatelessWidget {
|
||||
const BankAccountFlowScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(providers: [
|
||||
BlocProvider<BankAccountBloc>(
|
||||
create: (context) => BankAccountBloc()..add(BankAccountEventInit()),
|
||||
),
|
||||
], child: const AutoRouter());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/core/presentation/widgets/scroll_layout_helper.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_input.dart';
|
||||
import 'package:krow/features/profile/bank_account/domain/bloc/bank_account_bloc.dart';
|
||||
|
||||
@RoutePage()
|
||||
class BankAccountEditScreen extends StatefulWidget {
|
||||
const BankAccountEditScreen({super.key});
|
||||
|
||||
@override
|
||||
State<BankAccountEditScreen> createState() => _BankAccountEditScreenState();
|
||||
}
|
||||
|
||||
class _BankAccountEditScreenState extends State<BankAccountEditScreen> {
|
||||
Map<String, BankAccField>? bankAccInfo;
|
||||
final Map<String, TextEditingController> _controllers = {};
|
||||
|
||||
var showInputError = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
bankAccInfo = Map.of(
|
||||
BlocProvider.of<BankAccountBloc>(context).state.mapFromBankAccount());
|
||||
|
||||
bankAccInfo?.keys.forEach((key) {
|
||||
_controllers[key] =
|
||||
TextEditingController(text: bankAccInfo?[key]?.value);
|
||||
});
|
||||
setState(() {});
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<BankAccountBloc, BankAccountState>(
|
||||
listener: (context, state) {
|
||||
if (state.success) {
|
||||
context.router.maybePop();
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Scaffold(
|
||||
appBar: KwAppBar(
|
||||
titleText: 'edit_bank_account'.tr(),
|
||||
showNotification: true,
|
||||
),
|
||||
body: ScrollLayoutHelper(
|
||||
padding: const EdgeInsets.all(16),
|
||||
upperWidget: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'edit_information_below'.tr(),
|
||||
style: AppTextStyles.bodySmallReg
|
||||
.copyWith(color: AppColors.blackGray),
|
||||
),
|
||||
const Gap(24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: KwBoxDecorations.primaryLight12,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildCardHeader(context, 'account_details'.tr()),
|
||||
...(bankAccInfo?.entries
|
||||
.take(4)
|
||||
.map((entry) =>
|
||||
_buildInput(entry.key, entry.value))
|
||||
.toList() ??
|
||||
[])
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: KwBoxDecorations.primaryLight12,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildCardHeader(context, 'billing_address'.tr()),
|
||||
...(bankAccInfo?.entries
|
||||
.skip(4)
|
||||
.map((entry) =>
|
||||
_buildInput(entry.key, entry.value))
|
||||
.toList() ??
|
||||
[])
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(24),
|
||||
],
|
||||
),
|
||||
lowerWidget: KwButton.primary(
|
||||
label: 'save_changes'.tr(),
|
||||
onPressed: () {
|
||||
if (bankAccInfo?.values.any((element) =>
|
||||
!element.optional &&
|
||||
(element.value?.isEmpty ?? false)) ??
|
||||
false) {
|
||||
setState(() {
|
||||
showInputError = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var newAcc = bankAccInfo?.map((key, value) => MapEntry(
|
||||
key,
|
||||
_controllers[key]?.text ?? '',
|
||||
));
|
||||
|
||||
BlocProvider.of<BankAccountBloc>(context)
|
||||
.add(BankAccountEventUpdate(newAcc!));
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCardHeader(BuildContext context, String title) {
|
||||
return Text(
|
||||
title,
|
||||
style: AppTextStyles.bodyMediumMed,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInput(String key, BankAccField field) {
|
||||
var hasError = !field.optional &&
|
||||
(_controllers[key]?.text.isEmpty ?? false) &&
|
||||
showInputError;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: KwTextInput(
|
||||
hintText: '',
|
||||
title: field.title,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
field.value = value;
|
||||
});
|
||||
|
||||
},
|
||||
helperText: hasError ? 'field_cant_be_empty'.tr() : null,
|
||||
showError: hasError,
|
||||
controller: _controllers[key],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/application/routing/routes.gr.dart';
|
||||
import 'package:krow/core/presentation/gen/assets.gen.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/core/presentation/widgets/scroll_layout_helper.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
|
||||
import 'package:krow/features/profile/bank_account/domain/bloc/bank_account_bloc.dart';
|
||||
|
||||
@RoutePage()
|
||||
class BankAccountScreen extends StatefulWidget implements AutoRouteWrapper {
|
||||
const BankAccountScreen({super.key});
|
||||
|
||||
@override
|
||||
State<BankAccountScreen> createState() => _BankAccountScreenState();
|
||||
|
||||
@override
|
||||
Widget wrappedRoute(BuildContext context) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class _BankAccountScreenState extends State<BankAccountScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<BankAccountBloc, BankAccountState>(
|
||||
builder: (context, state) {
|
||||
return Scaffold(
|
||||
appBar: KwAppBar(
|
||||
titleText: 'bank_account'.tr(),
|
||||
showNotification: true,
|
||||
),
|
||||
body: ScrollLayoutHelper(
|
||||
padding: const EdgeInsets.all(16),
|
||||
upperWidget: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'securely_manage_bank_account'.tr(),
|
||||
style: AppTextStyles.bodySmallReg
|
||||
.copyWith(color: AppColors.blackGray),
|
||||
),
|
||||
const Gap(24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: KwBoxDecorations.primaryLight12,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildCardHeader(context),
|
||||
...(state
|
||||
.mapFromBankAccount()
|
||||
.entries
|
||||
.map((entry) => infoRow(
|
||||
entry.value.title, entry.value.value ?? ''))
|
||||
.expand((e) => e)
|
||||
.toList())
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
lowerWidget: Container(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Row _buildCardHeader(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'your_payment_details'.tr(),
|
||||
style: AppTextStyles.bodyMediumMed,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.router.push(const BankAccountEditRoute());
|
||||
},
|
||||
child: Assets.images.icons.edit.svg(height: 16, width: 16))
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> infoRow(
|
||||
String title,
|
||||
String value,
|
||||
) {
|
||||
return [
|
||||
const Gap(24),
|
||||
Text(
|
||||
'$title:'.toUpperCase(),
|
||||
style: AppTextStyles.captionReg.copyWith(color: AppColors.blackGray),
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
value,
|
||||
style: AppTextStyles.bodyMediumMed,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:krow/features/profile/benefits/domain/benefits_repository.dart';
|
||||
import 'package:krow/features/profile/benefits/domain/entities/benefit_entity.dart';
|
||||
import 'package:krow/features/profile/benefits/domain/entities/benefit_record_entity.dart';
|
||||
|
||||
@Injectable(as: BenefitsRepository)
|
||||
class BenefitsRepositoryImpl implements BenefitsRepository {
|
||||
static final _benefitsMock = [
|
||||
BenefitEntity(
|
||||
name: 'Sick Leave',
|
||||
requirement: 'You need at least 8 hours to request sick leave',
|
||||
requiredHours: 40,
|
||||
currentHours: 10,
|
||||
isClaimed: false,
|
||||
info: 'Listed certificates are mandatory for employees. If the employee '
|
||||
'does not have the complete certificates, they can’t proceed with '
|
||||
'their registration.',
|
||||
history: [
|
||||
BenefitRecordEntity(
|
||||
createdAt: DateTime(2024, 6, 14),
|
||||
status: RecordStatus.submitted,
|
||||
),
|
||||
BenefitRecordEntity(
|
||||
createdAt: DateTime(2023, 6, 5),
|
||||
status: RecordStatus.submitted,
|
||||
),
|
||||
BenefitRecordEntity(
|
||||
createdAt: DateTime(2019, 6, 4),
|
||||
status: RecordStatus.submitted,
|
||||
),
|
||||
BenefitRecordEntity(
|
||||
createdAt: DateTime(2018, 6, 1),
|
||||
status: RecordStatus.submitted,
|
||||
),
|
||||
BenefitRecordEntity(
|
||||
createdAt: DateTime(2017, 6, 24),
|
||||
status: RecordStatus.submitted,
|
||||
),
|
||||
BenefitRecordEntity(
|
||||
createdAt: DateTime(2016, 6, 15),
|
||||
status: RecordStatus.submitted,
|
||||
),
|
||||
BenefitRecordEntity(
|
||||
createdAt: DateTime(2015, 6, 6),
|
||||
status: RecordStatus.submitted,
|
||||
),
|
||||
],
|
||||
),
|
||||
const BenefitEntity(
|
||||
name: 'Vacation',
|
||||
requirement: 'You need 40 hours to claim vacation pay',
|
||||
requiredHours: 40,
|
||||
currentHours: 40,
|
||||
isClaimed: false,
|
||||
info: 'Listed certificates are mandatory for employees. If the employee '
|
||||
'does not have the complete certificates, they can’t proceed with '
|
||||
'their registration.',
|
||||
history: [],
|
||||
),
|
||||
const BenefitEntity(
|
||||
name: 'Holidays',
|
||||
requirement: 'Pay holidays: Thanksgiving, Christmas, New Year',
|
||||
requiredHours: 24,
|
||||
currentHours: 1,
|
||||
isClaimed: false,
|
||||
info: 'Listed certificates are mandatory for employees. If the employee '
|
||||
'does not have the complete certificates, they can’t proceed with '
|
||||
'their registration.',
|
||||
history: [],
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<BenefitEntity>> getStaffBenefits() async {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
return _benefitsMock;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BenefitEntity?> requestBenefit({
|
||||
required BenefitEntity benefit,
|
||||
}) async {
|
||||
if (benefit.currentHours != benefit.requiredHours || benefit.isClaimed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
return benefit.copyWith(isClaimed: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import 'package:krow/features/profile/benefits/domain/entities/benefit_entity.dart';
|
||||
|
||||
abstract interface class BenefitsRepository {
|
||||
Future<List<BenefitEntity>> getStaffBenefits();
|
||||
|
||||
Future<BenefitEntity?> requestBenefit({required BenefitEntity benefit});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/core/application/di/injectable.dart';
|
||||
import 'package:krow/core/data/enums/state_status.dart';
|
||||
import 'package:krow/features/profile/benefits/domain/benefits_repository.dart';
|
||||
import 'package:krow/features/profile/benefits/domain/entities/benefit_entity.dart';
|
||||
|
||||
part 'benefits_event.dart';
|
||||
|
||||
part 'benefits_state.dart';
|
||||
|
||||
class BenefitsBloc extends Bloc<BenefitsEvent, BenefitsState> {
|
||||
BenefitsBloc() : super(const BenefitsState()) {
|
||||
on<InitializeBenefits>((event, emit) async {
|
||||
emit(state.copyWith(status: StateStatus.loading));
|
||||
|
||||
final benefits = await _repository.getStaffBenefits();
|
||||
|
||||
emit(state.copyWith(status: StateStatus.idle, benefits: benefits));
|
||||
});
|
||||
|
||||
on<SendBenefitRequest>((event, emit) async {
|
||||
emit(state.copyWith(status: StateStatus.loading));
|
||||
final result = await _repository.requestBenefit(benefit: event.benefit);
|
||||
|
||||
int index = -1;
|
||||
List<BenefitEntity>? updatedBenefits;
|
||||
if (result != null) {
|
||||
index = state.benefits.indexWhere(
|
||||
(benefit) => benefit.name == result.name,
|
||||
);
|
||||
|
||||
if (index >= 0) {
|
||||
updatedBenefits = List.from(state.benefits)..[index] = result;
|
||||
}
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
benefits: updatedBenefits,
|
||||
status: StateStatus.idle,
|
||||
),
|
||||
);
|
||||
event.requestCompleter.complete(result ?? event.benefit);
|
||||
});
|
||||
}
|
||||
|
||||
final BenefitsRepository _repository = getIt<BenefitsRepository>();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
part of 'benefits_bloc.dart';
|
||||
|
||||
@immutable
|
||||
sealed class BenefitsEvent {
|
||||
const BenefitsEvent();
|
||||
}
|
||||
|
||||
class InitializeBenefits extends BenefitsEvent {
|
||||
const InitializeBenefits();
|
||||
}
|
||||
|
||||
class SendBenefitRequest extends BenefitsEvent {
|
||||
const SendBenefitRequest({
|
||||
required this.benefit,
|
||||
required this.requestCompleter,
|
||||
});
|
||||
|
||||
final BenefitEntity benefit;
|
||||
final Completer<BenefitEntity> requestCompleter;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
part of 'benefits_bloc.dart';
|
||||
|
||||
@immutable
|
||||
class BenefitsState {
|
||||
const BenefitsState({
|
||||
this.status = StateStatus.idle,
|
||||
this.benefits = const [],
|
||||
this.exception,
|
||||
});
|
||||
|
||||
final StateStatus status;
|
||||
final List<BenefitEntity> benefits;
|
||||
final Exception? exception;
|
||||
|
||||
BenefitsState copyWith({
|
||||
StateStatus? status,
|
||||
List<BenefitEntity>? benefits,
|
||||
Exception? exception,
|
||||
}) {
|
||||
return BenefitsState(
|
||||
status: status ?? this.status,
|
||||
benefits: benefits ?? this.benefits,
|
||||
exception: exception,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:krow/features/profile/benefits/domain/entities/benefit_record_entity.dart';
|
||||
|
||||
@immutable
|
||||
class BenefitEntity {
|
||||
const BenefitEntity({
|
||||
required this.name,
|
||||
required this.requirement,
|
||||
required this.requiredHours,
|
||||
required this.currentHours,
|
||||
required this.isClaimed,
|
||||
required this.info,
|
||||
required this.history,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String requirement;
|
||||
final int requiredHours;
|
||||
final int currentHours;
|
||||
final bool isClaimed;
|
||||
final String info;
|
||||
final List<BenefitRecordEntity> history;
|
||||
|
||||
double get progress {
|
||||
final progress = currentHours / requiredHours;
|
||||
return progress > 1 ? 1 : progress;
|
||||
}
|
||||
|
||||
BenefitEntity copyWith({
|
||||
String? name,
|
||||
String? requirement,
|
||||
int? requiredHours,
|
||||
int? currentHours,
|
||||
bool? isClaimed,
|
||||
String? info,
|
||||
List<BenefitRecordEntity>? history,
|
||||
}) {
|
||||
return BenefitEntity(
|
||||
name: name ?? this.name,
|
||||
requirement: requirement ?? this.requirement,
|
||||
requiredHours: requiredHours ?? this.requiredHours,
|
||||
currentHours: currentHours ?? this.currentHours,
|
||||
isClaimed: isClaimed ?? this.isClaimed,
|
||||
info: info ?? this.info,
|
||||
history: history ?? this.history,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class BenefitRecordEntity {
|
||||
const BenefitRecordEntity({required this.createdAt, required this.status});
|
||||
|
||||
final DateTime createdAt;
|
||||
final RecordStatus status;
|
||||
}
|
||||
|
||||
enum RecordStatus { pending, submitted }
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/data/enums/state_status.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_loading_overlay.dart';
|
||||
import 'package:krow/features/profile/benefits/domain/bloc/benefits_bloc.dart';
|
||||
import 'package:krow/features/profile/benefits/presentation/widgets/benefit_card_widget.dart';
|
||||
|
||||
@RoutePage()
|
||||
class BenefitsScreen extends StatefulWidget implements AutoRouteWrapper {
|
||||
const BenefitsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<BenefitsScreen> createState() => _BenefitsScreenState();
|
||||
|
||||
@override
|
||||
Widget wrappedRoute(BuildContext context) {
|
||||
return BlocProvider<BenefitsBloc>(
|
||||
create: (context) => BenefitsBloc()..add(const InitializeBenefits()),
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BenefitsScreenState extends State<BenefitsScreen> {
|
||||
final OverlayPortalController _controller = OverlayPortalController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: KwLoadingOverlay(
|
||||
controller: _controller,
|
||||
child: BlocListener<BenefitsBloc, BenefitsState>(
|
||||
listenWhen: (previous, current) => previous.status != current.status,
|
||||
listener: (context, state) {
|
||||
if (state.status == StateStatus.loading) {
|
||||
_controller.show();
|
||||
} else {
|
||||
_controller.hide();
|
||||
}
|
||||
},
|
||||
child: CustomScrollView(
|
||||
primary: false,
|
||||
slivers: [
|
||||
SliverList.list(
|
||||
children: [
|
||||
KwAppBar(
|
||||
titleText: 'your_benefits_overview'.tr(),
|
||||
showNotification: true,
|
||||
),
|
||||
const Gap(16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'manage_and_track_benefits'.tr(),
|
||||
style: AppTextStyles.bodySmallReg.copyWith(
|
||||
color: AppColors.blackGray,
|
||||
),
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 120),
|
||||
sliver: BlocBuilder<BenefitsBloc, BenefitsState>(
|
||||
buildWhen: (current, previous) =>
|
||||
current.benefits != previous.benefits,
|
||||
builder: (context, state) {
|
||||
return SliverList.separated(
|
||||
itemCount: state.benefits.length,
|
||||
separatorBuilder: (context, index) {
|
||||
return const SizedBox(height: 12);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
return BenefitCardWidget(
|
||||
benefit: state.benefits[index],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/core/presentation/gen/assets.gen.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/dialogs/kw_dialog.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
|
||||
import 'package:krow/features/profile/benefits/domain/bloc/benefits_bloc.dart';
|
||||
import 'package:krow/features/profile/benefits/domain/entities/benefit_entity.dart';
|
||||
import 'package:krow/features/profile/benefits/presentation/widgets/benefit_history_widget.dart';
|
||||
|
||||
class BenefitCardWidget extends StatefulWidget {
|
||||
const BenefitCardWidget({super.key, required this.benefit});
|
||||
|
||||
final BenefitEntity benefit;
|
||||
|
||||
@override
|
||||
State<BenefitCardWidget> createState() => _BenefitCardWidgetState();
|
||||
}
|
||||
|
||||
class _BenefitCardWidgetState extends State<BenefitCardWidget>
|
||||
with TickerProviderStateMixin {
|
||||
late final AnimationController _animationController;
|
||||
late BenefitEntity _benefit = widget.benefit;
|
||||
Completer<BenefitEntity>? _requestCompleter;
|
||||
double _progress = 0;
|
||||
bool _isReady = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_progress = widget.benefit.progress;
|
||||
_isReady = _progress == 1;
|
||||
|
||||
super.initState();
|
||||
|
||||
_animationController = AnimationController(vsync: this);
|
||||
_animationController.animateTo(
|
||||
_progress,
|
||||
duration: const Duration(seconds: 1),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleRequestPress() async {
|
||||
_requestCompleter?.completeError(Exception('previous_aborted'.tr()));
|
||||
final completer = _requestCompleter = Completer<BenefitEntity>();
|
||||
|
||||
this.context.read<BenefitsBloc>().add(
|
||||
SendBenefitRequest(
|
||||
benefit: _benefit,
|
||||
requestCompleter: completer,
|
||||
),
|
||||
);
|
||||
|
||||
final benefit = await completer.future;
|
||||
if (!benefit.isClaimed) return;
|
||||
|
||||
setState(() {
|
||||
_progress = 0;
|
||||
_isReady = false;
|
||||
_benefit = _benefit.copyWith(currentHours: 0);
|
||||
});
|
||||
|
||||
await _animationController.animateTo(
|
||||
_progress,
|
||||
duration: const Duration(seconds: 1),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
|
||||
final context = this.context;
|
||||
if (!context.mounted) return;
|
||||
|
||||
await KwDialog.show(
|
||||
context: context,
|
||||
icon: Assets.images.icons.like,
|
||||
state: KwDialogState.positive,
|
||||
title: 'request_submitted'.tr(),
|
||||
message: 'request_submitted_message'.tr(args: [_benefit.name.toLowerCase()]),
|
||||
primaryButtonLabel: 'back_to_profile'.tr(),
|
||||
onPrimaryButtonPressed: (dialogContext) {
|
||||
Navigator.maybePop(dialogContext);
|
||||
context.maybePop();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.grayPrimaryFrame,
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Stack(
|
||||
alignment: AlignmentDirectional.center,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, _) {
|
||||
return CircularProgressIndicator(
|
||||
constraints:
|
||||
BoxConstraints.tight(const Size.square(90)),
|
||||
strokeWidth: 8,
|
||||
strokeCap: StrokeCap.round,
|
||||
backgroundColor: AppColors.bgColorLight,
|
||||
color: _isReady
|
||||
? AppColors.statusSuccess
|
||||
: AppColors.primaryBlue,
|
||||
value: _animationController.value,
|
||||
);
|
||||
},
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
text: '${_benefit.currentHours}/',
|
||||
style: AppTextStyles.headingH3,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${_benefit.requiredHours}',
|
||||
style: AppTextStyles.headingH3.copyWith(
|
||||
color: _isReady
|
||||
? AppColors.blackBlack
|
||||
: AppColors.blackCaptionText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'hours'.tr().toLowerCase(),
|
||||
style: AppTextStyles.captionReg
|
||||
.copyWith(color: AppColors.blackCaptionText),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
const SizedBox(height: 90),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_benefit.name, style: AppTextStyles.headingH3),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_benefit.requirement,
|
||||
style: AppTextStyles.bodySmallReg.copyWith(
|
||||
color: AppColors.blackGray,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
top: -4,
|
||||
right: 0,
|
||||
child: Assets.images.icons.alertCircle.svg(
|
||||
height: 16,
|
||||
width: 16,
|
||||
colorFilter: const ColorFilter.mode(
|
||||
AppColors.grayStroke,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_isReady)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.grayWhite,
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.tintGreen,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: SizedBox.square(
|
||||
dimension: 28,
|
||||
child: Assets.images.icons.checkCircle.svg(
|
||||
height: 10,
|
||||
width: 10,
|
||||
fit: BoxFit.scaleDown,
|
||||
colorFilter: const ColorFilter.mode(
|
||||
AppColors.statusSuccess,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_benefit.info,
|
||||
style: AppTextStyles.bodyTinyMed.copyWith(
|
||||
color: AppColors.statusSuccess,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
BenefitHistoryWidget(benefit: _benefit),
|
||||
if (_isReady)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
child: KwButton.primary(
|
||||
label: '${'request_payment_for'.tr()} ${_benefit.name}',
|
||||
onPressed: _handleRequestPress,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// TODO: implement dispose
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/features/profile/benefits/domain/entities/benefit_entity.dart';
|
||||
import 'package:krow/features/profile/benefits/domain/entities/benefit_record_entity.dart';
|
||||
|
||||
class BenefitHistoryWidget extends StatelessWidget {
|
||||
const BenefitHistoryWidget({super.key, required this.benefit});
|
||||
|
||||
final BenefitEntity benefit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ExpansionTile(
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
childrenPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
collapsedShape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
backgroundColor: AppColors.graySecondaryFrame,
|
||||
collapsedBackgroundColor: AppColors.graySecondaryFrame,
|
||||
iconColor: AppColors.blackBlack,
|
||||
collapsedIconColor: AppColors.blackBlack,
|
||||
title: Text(
|
||||
'${benefit.name} ${'history'.tr()}'.toUpperCase(),
|
||||
style: AppTextStyles.captionBold,
|
||||
),
|
||||
children: [
|
||||
const Divider(
|
||||
thickness: 1,
|
||||
color: AppColors.tintGray,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (benefit.history.isEmpty)
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: Center(
|
||||
child: Text('no_history_yet'.tr()),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 168,
|
||||
child: RawScrollbar(
|
||||
padding: EdgeInsets.zero,
|
||||
thumbVisibility: true,
|
||||
trackVisibility: true,
|
||||
thumbColor: AppColors.grayStroke,
|
||||
trackColor: AppColors.grayTintStroke,
|
||||
trackRadius: const Radius.circular(8),
|
||||
radius: const Radius.circular(8),
|
||||
trackBorderColor: Colors.transparent,
|
||||
thickness: 5,
|
||||
minOverscrollLength: 0,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsetsDirectional.only(end: 10),
|
||||
itemCount: benefit.history.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
return _HistoryRecordWidget(record: benefit.history[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HistoryRecordWidget extends StatelessWidget {
|
||||
const _HistoryRecordWidget({required this.record});
|
||||
|
||||
static final _dateFormat = DateFormat('d MMM, yyyy');
|
||||
|
||||
final BenefitRecordEntity record;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color color = switch (record.status) {
|
||||
RecordStatus.pending => AppColors.primaryBlue,
|
||||
RecordStatus.submitted => AppColors.statusSuccess,
|
||||
};
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_dateFormat.format(record.createdAt),
|
||||
style: AppTextStyles.bodySmallReg.copyWith(
|
||||
color: AppColors.blackGray,
|
||||
),
|
||||
),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
||||
border: Border.all(color: color),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: Text(
|
||||
switch (record.status) {
|
||||
RecordStatus.pending => 'pending'.tr(),
|
||||
RecordStatus.submitted => 'submitted'.tr(),
|
||||
},
|
||||
style: AppTextStyles.bodySmallReg.copyWith(
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:krow/core/application/clients/api/api_client.dart';
|
||||
import 'package:krow/core/data/models/pagination_wrapper/pagination_wrapper.dart';
|
||||
import 'package:krow/features/profile/certificates/data/models/certificate_model.dart';
|
||||
import 'package:krow/features/profile/certificates/data/models/staff_certificate.dart';
|
||||
|
||||
import 'certificates_gql.dart';
|
||||
|
||||
@injectable
|
||||
class CertificatesApiProvider {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
CertificatesApiProvider(this._apiClient);
|
||||
|
||||
Future<List<CertificateModel>> fetchCertificates() async {
|
||||
var result = await _apiClient.query(schema: getCertificatesQuery);
|
||||
|
||||
if (result.hasException) {
|
||||
throw Exception(result.exception.toString());
|
||||
}
|
||||
|
||||
return result.data!['certificates'].map<CertificateModel>((e) {
|
||||
return CertificateModel.fromJson(e);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<PaginationWrapper<StaffCertificate>> fetchStaffCertificates() async {
|
||||
var result = await _apiClient.query(schema: getStaffCertificatesQuery);
|
||||
|
||||
if (result.hasException) {
|
||||
throw Exception(result.exception.toString());
|
||||
}
|
||||
|
||||
return PaginationWrapper.fromJson(result.data!['staff_certificates'],
|
||||
(json) => StaffCertificate.fromJson(json));
|
||||
}
|
||||
|
||||
Future<StaffCertificate> putStaffCertificate(
|
||||
String certificateId, String imagePath, String certificateDate) async {
|
||||
var byteData = File(imagePath).readAsBytesSync();
|
||||
|
||||
var multipartFile = MultipartFile.fromBytes(
|
||||
'file',
|
||||
byteData,
|
||||
filename: '${DateTime.now().millisecondsSinceEpoch}.jpg',
|
||||
contentType: MediaType('image', 'jpg'),
|
||||
);
|
||||
|
||||
final Map<String, dynamic> variables = {
|
||||
'certificate_id': certificateId,
|
||||
'expiration_date': certificateDate,
|
||||
'file': multipartFile,
|
||||
};
|
||||
var result = await _apiClient.mutate(
|
||||
schema: putStaffCertificateMutation, body: {'input': variables});
|
||||
|
||||
if (result.hasException) {
|
||||
throw Exception(result.exception.toString());
|
||||
} else {
|
||||
return StaffCertificate.fromJson(
|
||||
result.data!['upload_staff_certificate']);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteStaffCertificate(String certificateId) async {
|
||||
final Map<String, dynamic> variables = {
|
||||
'id': certificateId,
|
||||
};
|
||||
var result = await _apiClient.mutate(
|
||||
schema: deleteStaffCertificateMutation, body: variables);
|
||||
|
||||
if (result.hasException) {
|
||||
throw Exception(result.exception.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
const String getCertificatesQuery = '''
|
||||
{
|
||||
certificates {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
const String getStaffCertificatesQuery = '''
|
||||
query fetchStaffCertificates () {
|
||||
staff_certificates(first: 10) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
expiration_date
|
||||
status
|
||||
file
|
||||
certificate {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
const String putStaffCertificateMutation = '''
|
||||
mutation UploadStaffCertificate(\$input: UploadStaffCertificateInput!) {
|
||||
upload_staff_certificate(input: \$input) {
|
||||
id
|
||||
expiration_date
|
||||
status
|
||||
file
|
||||
certificate {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
const String deleteStaffCertificateMutation = '''
|
||||
mutation DeleteStaffCertificate(\$id: ID!) {
|
||||
delete_staff_certificate(id: \$id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
''';
|
||||
@@ -0,0 +1,24 @@
|
||||
class CertificateModel {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
|
||||
CertificateModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
factory CertificateModel.fromJson(Map<String, dynamic> json) {
|
||||
return CertificateModel(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:krow/features/profile/certificates/data/certificates_api_provider.dart';
|
||||
import 'package:krow/features/profile/certificates/domain/certificates_repository.dart';
|
||||
import 'package:krow/features/profile/certificates/data/models/certificate_model.dart';
|
||||
import 'package:krow/features/profile/certificates/data/models/staff_certificate.dart';
|
||||
|
||||
@Injectable(as: CertificatesRepository)
|
||||
class CertificatesRepositoryImpl extends CertificatesRepository {
|
||||
final CertificatesApiProvider _certificatesApiProvider;
|
||||
|
||||
CertificatesRepositoryImpl(this._certificatesApiProvider);
|
||||
|
||||
@override
|
||||
Future<List<CertificateModel>> getCertificates() async {
|
||||
return _certificatesApiProvider.fetchCertificates();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StaffCertificate> putStaffCertificate(
|
||||
String certificateId, String imagePath, String certificateDate) {
|
||||
return _certificatesApiProvider.putStaffCertificate(
|
||||
certificateId, imagePath, certificateDate);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<StaffCertificate>> getStaffCertificates() async{
|
||||
return (await _certificatesApiProvider.fetchStaffCertificates()).edges.map((e) => e.node).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteStaffCertificate(String certificateId) {
|
||||
return _certificatesApiProvider.deleteStaffCertificate(certificateId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:krow/features/profile/certificates/data/models/certificate_model.dart';
|
||||
|
||||
part 'staff_certificate.g.dart';
|
||||
|
||||
enum CertificateStatus {
|
||||
verified,
|
||||
pending,
|
||||
declined,
|
||||
}
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake)
|
||||
class StaffCertificate {
|
||||
final String id;
|
||||
CertificateModel certificate;
|
||||
final String expirationDate;
|
||||
final CertificateStatus status;
|
||||
final String file;
|
||||
|
||||
StaffCertificate(
|
||||
{required this.id,
|
||||
required this.certificate,
|
||||
required this.expirationDate,
|
||||
required this.status,
|
||||
required this.file});
|
||||
|
||||
factory StaffCertificate.fromJson(Map<String, dynamic> json) {
|
||||
return _$StaffCertificateFromJson(json);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => _$StaffCertificateToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/core/application/di/injectable.dart';
|
||||
import 'package:krow/features/profile/certificates/domain/certificates_repository.dart';
|
||||
import 'package:krow/features/profile/certificates/data/models/staff_certificate.dart';
|
||||
import 'package:krow/features/profile/certificates/domain/bloc/certificates_event.dart';
|
||||
import 'package:krow/features/profile/certificates/domain/bloc/certificates_state.dart';
|
||||
|
||||
class CertificatesBloc extends Bloc<CertificatesEvent, CertificatesState> {
|
||||
CertificatesBloc() : super(CertificatesState()) {
|
||||
on<CertificatesEventFetch>(_onFetch);
|
||||
on<CertificatesEventSubmit>(_onSubmit);
|
||||
on<CertificatesEventUpload>(_onUploadPhoto);
|
||||
on<CertificatesEventDelete>(_onDeleteCertificate);
|
||||
}
|
||||
|
||||
void _onFetch(
|
||||
CertificatesEventFetch event, Emitter<CertificatesState> emit) async {
|
||||
emit(state.copyWith(loading: true));
|
||||
var certificates = await getIt<CertificatesRepository>().getCertificates();
|
||||
List<StaffCertificate> staffCertificates =
|
||||
await getIt<CertificatesRepository>().getStaffCertificates();
|
||||
var items = certificates.map((certificate) {
|
||||
var staffCertificate = staffCertificates.firstWhereOrNull(
|
||||
(e) => certificate.id == e.certificate.id,
|
||||
);
|
||||
|
||||
return CertificatesViewModel(
|
||||
id: staffCertificate?.id,
|
||||
certificateId: certificate.id,
|
||||
title: certificate.name,
|
||||
imageUrl: staffCertificate?.file,
|
||||
expirationDate: staffCertificate?.expirationDate,
|
||||
status: staffCertificate?.status,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
emit(state.copyWith(
|
||||
loading: false,
|
||||
certificatesItems: items,
|
||||
));
|
||||
}
|
||||
|
||||
void _onUploadPhoto(
|
||||
CertificatesEventUpload event, Emitter<CertificatesState> emit) async {
|
||||
event.item.uploading = true;
|
||||
emit(state.copyWith());
|
||||
|
||||
try {
|
||||
var split = event.expirationDate.split('.').reversed;
|
||||
split = [split.elementAt(0), split.elementAt(2), split.elementAt(1)];
|
||||
var formattedDate = split.join('-');
|
||||
var newCertificate = await getIt<CertificatesRepository>()
|
||||
.putStaffCertificate(
|
||||
event.item.certificateId, event.path, formattedDate);
|
||||
event.item.applyNewStaffCertificate(newCertificate);
|
||||
} finally {
|
||||
event.item.uploading = false;
|
||||
emit(state.copyWith());
|
||||
}
|
||||
}
|
||||
|
||||
void _onSubmit(
|
||||
CertificatesEventSubmit event, Emitter<CertificatesState> emit) async {
|
||||
final allCertUploaded = state.certificatesItems.every((element) {
|
||||
return element.certificateId == '3' ||
|
||||
element.status == CertificateStatus.pending ||
|
||||
element.status == CertificateStatus.verified;
|
||||
});
|
||||
|
||||
if (allCertUploaded) {
|
||||
emit(state.copyWith(success: true));
|
||||
} else {
|
||||
emit(state.copyWith(showError: true));
|
||||
}
|
||||
}
|
||||
|
||||
void _onDeleteCertificate(
|
||||
CertificatesEventDelete event, Emitter<CertificatesState> emit) async {
|
||||
emit(state.copyWith(loading: true));
|
||||
try {
|
||||
await getIt<CertificatesRepository>()
|
||||
.deleteStaffCertificate(event.item.id ?? '0');
|
||||
state.certificatesItems
|
||||
.firstWhere((element) => element.id == event.item.id)
|
||||
.clear();
|
||||
} finally {
|
||||
emit(state.copyWith(loading: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:krow/features/profile/certificates/domain/bloc/certificates_state.dart';
|
||||
|
||||
sealed class CertificatesEvent {}
|
||||
|
||||
class CertificatesEventFetch extends CertificatesEvent {}
|
||||
|
||||
class CertificatesEventSubmit extends CertificatesEvent {}
|
||||
|
||||
class CertificatesEventUpload extends CertificatesEvent {
|
||||
final String path;
|
||||
final String expirationDate;
|
||||
final CertificatesViewModel item;
|
||||
|
||||
CertificatesEventUpload(
|
||||
{required this.path, required this.expirationDate, required this.item});
|
||||
}
|
||||
|
||||
class CertificatesEventDelete extends CertificatesEvent {
|
||||
final CertificatesViewModel item;
|
||||
|
||||
CertificatesEventDelete(this.item);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:krow/features/profile/certificates/data/models/staff_certificate.dart';
|
||||
|
||||
class CertificatesState {
|
||||
final bool showError;
|
||||
final bool loading;
|
||||
final bool success;
|
||||
List<CertificatesViewModel> certificatesItems = [];
|
||||
|
||||
CertificatesState(
|
||||
{this.showError = false,
|
||||
this.certificatesItems = const [],
|
||||
this.loading = false,
|
||||
this.success = false});
|
||||
|
||||
copyWith(
|
||||
{bool? showError,
|
||||
List<CertificatesViewModel>? certificatesItems,
|
||||
bool? loading,
|
||||
bool? success}) {
|
||||
return CertificatesState(
|
||||
showError: showError ?? this.showError,
|
||||
certificatesItems: certificatesItems ?? this.certificatesItems,
|
||||
loading: loading ?? false,
|
||||
success: success ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CertificatesViewModel {
|
||||
String? id;
|
||||
String certificateId;
|
||||
final String title;
|
||||
bool uploading;
|
||||
String? imageUrl;
|
||||
String? expirationDate;
|
||||
CertificateStatus? status;
|
||||
|
||||
CertificatesViewModel({
|
||||
this.id,
|
||||
required this.certificateId,
|
||||
required this.title,
|
||||
this.expirationDate,
|
||||
this.imageUrl,
|
||||
this.uploading = false,
|
||||
this.status,
|
||||
});
|
||||
|
||||
void clear() {
|
||||
id = null;
|
||||
imageUrl = null;
|
||||
expirationDate = null;
|
||||
status = null;
|
||||
}
|
||||
|
||||
void applyNewStaffCertificate(StaffCertificate newCertificate) {
|
||||
id = newCertificate.id;
|
||||
imageUrl = newCertificate.file;
|
||||
expirationDate = newCertificate.expirationDate;
|
||||
status = newCertificate.status;
|
||||
}
|
||||
|
||||
bool get isExpired {
|
||||
if (expirationDate == null) return false;
|
||||
DateTime expiration = DateTime.parse(expirationDate!);
|
||||
DateTime now = DateTime.now();
|
||||
return now.isAfter(expiration);
|
||||
}
|
||||
|
||||
String getExpirationInfo() {
|
||||
if (expirationDate == null) return '';
|
||||
DateTime expiration = DateTime.parse(expirationDate!);
|
||||
DateTime now = DateTime.now();
|
||||
Duration difference = expiration.difference(now);
|
||||
String formatted =
|
||||
'${expiration.month.toString().padLeft(2, '0')}.${expiration.day.toString().padLeft(2, '0')}.${expiration.year}';
|
||||
|
||||
if (difference.inDays <= 0) {
|
||||
return '$formatted (expired)';
|
||||
} else if (difference.inDays < 14) {
|
||||
return '$formatted (expires in ${difference.inDays} days)';
|
||||
} else {
|
||||
return formatted;
|
||||
}
|
||||
}
|
||||
|
||||
bool expireSoon() {
|
||||
if (expirationDate == null) return false;
|
||||
DateTime expiration = DateTime.parse(expirationDate!);
|
||||
DateTime now = DateTime.now();
|
||||
Duration difference = expiration.difference(now);
|
||||
return difference.inDays <= 14;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:krow/features/profile/certificates/data/models/certificate_model.dart';
|
||||
import 'package:krow/features/profile/certificates/data/models/staff_certificate.dart';
|
||||
|
||||
abstract class CertificatesRepository {
|
||||
Future<List<CertificateModel>> getCertificates();
|
||||
|
||||
Future<List<StaffCertificate>> getStaffCertificates();
|
||||
|
||||
Future<StaffCertificate> putStaffCertificate(
|
||||
String certificateId, String imagePath, String certificateDate);
|
||||
|
||||
Future<void> deleteStaffCertificate(String certificateId);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:krow/core/presentation/gen/assets.gen.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_box_decorations.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/core/presentation/widgets/scroll_layout_helper.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
|
||||
import 'package:krow/core/presentation/widgets/uploud_image_card.dart';
|
||||
import 'package:krow/features/profile/certificates/data/models/staff_certificate.dart';
|
||||
import 'package:krow/features/profile/certificates/domain/bloc/certificates_bloc.dart';
|
||||
import 'package:krow/features/profile/certificates/domain/bloc/certificates_event.dart';
|
||||
import 'package:krow/features/profile/certificates/domain/bloc/certificates_state.dart';
|
||||
import 'package:krow/features/profile/certificates/presentation/screen/certificates_upload_dialog.dart';
|
||||
import 'package:modal_progress_hud_nsn/modal_progress_hud_nsn.dart';
|
||||
|
||||
@RoutePage()
|
||||
class CertificatesScreen extends StatelessWidget implements AutoRouteWrapper {
|
||||
const CertificatesScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget wrappedRoute(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => CertificatesBloc()..add(CertificatesEventFetch()),
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: KwAppBar(
|
||||
titleText: 'certificates'.tr(),
|
||||
),
|
||||
body: BlocConsumer<CertificatesBloc, CertificatesState>(
|
||||
listener: (context, state) {
|
||||
if (state.success) {
|
||||
context.router.maybePop();
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return ModalProgressHUD(
|
||||
inAsyncCall: state.loading,
|
||||
child: ScrollLayoutHelper(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
upperWidget: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'please_indicate_certificates'.tr(),
|
||||
style: AppTextStyles.bodyTinyMed
|
||||
.copyWith(color: AppColors.blackGray),
|
||||
),
|
||||
if (state.showError) _buildErrorMessage(),
|
||||
const Gap(24),
|
||||
_buildUploadProofItems(
|
||||
context,
|
||||
state.certificatesItems,
|
||||
state.showError,
|
||||
),
|
||||
],
|
||||
),
|
||||
lowerWidget: KwButton.primary(
|
||||
label: 'confirm'.tr(),
|
||||
onPressed: () {
|
||||
BlocProvider.of<CertificatesBloc>(context)
|
||||
.add(CertificatesEventSubmit());
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_buildUploadProofItems(
|
||||
context,
|
||||
List<CertificatesViewModel> items,
|
||||
bool showError,
|
||||
) {
|
||||
return Column(
|
||||
children: items.map(
|
||||
(item) {
|
||||
Color? statusColor = _getStatusColor(showError, item);
|
||||
|
||||
return UploadImageCard(
|
||||
title: item.title,
|
||||
onSelectImage: () {
|
||||
ImagePicker()
|
||||
.pickImage(source: ImageSource.gallery)
|
||||
.then((value) {
|
||||
if (value != null) {
|
||||
_showSelectCertificateDialog(context, item, value.path);
|
||||
}
|
||||
});
|
||||
},
|
||||
onDeleteTap: () {
|
||||
BlocProvider.of<CertificatesBloc>(context)
|
||||
.add(CertificatesEventDelete(item));
|
||||
},
|
||||
onTap: () {},
|
||||
imageUrl: item.imageUrl,
|
||||
inUploading: item.uploading,
|
||||
statusColor: statusColor,
|
||||
message: showError && item.imageUrl == null
|
||||
? 'availability_requires_confirmation'.tr()
|
||||
: item.status == null
|
||||
? 'supported_format'.tr()
|
||||
: (item.status!.name[0].toUpperCase() +
|
||||
item.status!.name.substring(1).toLowerCase()),
|
||||
hasError: showError,
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _buildExpirationRow(item),
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Color? _getStatusColor(bool showError, CertificatesViewModel item) {
|
||||
var statusColor = (showError && item.status == null) ||
|
||||
item.status == CertificateStatus.declined
|
||||
? AppColors.statusError
|
||||
: item.status == CertificateStatus.verified
|
||||
? AppColors.statusSuccess
|
||||
: item.status == CertificateStatus.pending
|
||||
? AppColors.primaryBlue
|
||||
: null;
|
||||
return statusColor;
|
||||
}
|
||||
|
||||
Container _buildErrorMessage() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 12),
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: KwBoxDecorations.primaryLight8,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 28,
|
||||
width: 28,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle, color: AppColors.tintRed),
|
||||
child: Center(
|
||||
child: Assets.images.icons.alertCircle.svg(),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'listed_certificates_mandatory'.tr(),
|
||||
style: AppTextStyles.bodyTinyMed
|
||||
.copyWith(color: AppColors.statusError),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExpirationRow(CertificatesViewModel item) {
|
||||
var bgColor = item.isExpired
|
||||
? AppColors.tintRed
|
||||
: item.expireSoon()
|
||||
? AppColors.tintYellow
|
||||
: AppColors.tintGray;
|
||||
|
||||
var textColor = item.isExpired
|
||||
? AppColors.statusError
|
||||
: item.expireSoon()
|
||||
? AppColors.statusWarningBody
|
||||
: null;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 6, right: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.grayWhite,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.only(left: 6, right: 6, top: 3, bottom: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor, borderRadius: BorderRadius.circular(4)),
|
||||
child: Text(
|
||||
'${'expiration_date'.tr()} ',
|
||||
style: AppTextStyles.bodyTinyReg.copyWith(color: textColor),
|
||||
)),
|
||||
const Gap(7),
|
||||
if (item.expirationDate != null)
|
||||
Text(
|
||||
item.getExpirationInfo(),
|
||||
style: AppTextStyles.bodyTinyMed
|
||||
.copyWith(color: textColor ?? AppColors.blackGray),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSelectCertificateDialog(
|
||||
context, CertificatesViewModel item, imagePath) {
|
||||
CertificatesUploadDialog.show(context, imagePath).then((expireDate) {
|
||||
if (expireDate != null) {
|
||||
BlocProvider.of<CertificatesBloc>(context).add(CertificatesEventUpload(
|
||||
item: item, path: imagePath, expirationDate: expireDate));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/application/common/text_formatters/expiration_date_formatter.dart';
|
||||
import 'package:krow/core/application/common/validators/certificate_date_validator.dart';
|
||||
import 'package:krow/core/presentation/gen/assets.gen.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_input.dart';
|
||||
|
||||
class CertificatesUploadDialog extends StatefulWidget {
|
||||
final String path;
|
||||
|
||||
const CertificatesUploadDialog({
|
||||
super.key,
|
||||
required this.path,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CertificatesUploadDialog> createState() =>
|
||||
_CertificatesUploadDialogState();
|
||||
|
||||
static Future<String?> show(BuildContext context, String path) {
|
||||
return showDialog<String?>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return CertificatesUploadDialog(
|
||||
path: path,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CertificatesUploadDialogState extends State<CertificatesUploadDialog> {
|
||||
final TextEditingController expiryDateController = TextEditingController();
|
||||
String? inputError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
expiryDateController.addListener(() {
|
||||
if(expiryDateController.text.length == 10) {
|
||||
inputError =
|
||||
CertificateDateValidator.validate(expiryDateController.text);
|
||||
}else{
|
||||
inputError = null;
|
||||
}
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Center(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.9),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.grayWhite,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
..._buildDialogTitle(context),
|
||||
Material(
|
||||
type: MaterialType.transparency,
|
||||
child: KwTextInput(
|
||||
title: 'expiry_date_1'.tr(),
|
||||
hintText: 'enter_certificate_expiry_date'.tr(),
|
||||
controller: expiryDateController,
|
||||
maxLength: 10,
|
||||
inputFormatters: [DateTextFormatter()],
|
||||
keyboardType: TextInputType.datetime,
|
||||
helperText: inputError,
|
||||
showError: inputError != null,
|
||||
),
|
||||
),
|
||||
const Gap(12),
|
||||
_buildImage(context),
|
||||
const Gap(24),
|
||||
KwButton.primary(
|
||||
label: 'save_certificate'.tr(),
|
||||
disabled: expiryDateController.text.length != 10 || inputError != null,
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(expiryDateController.text);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ClipRRect _buildImage(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.file(
|
||||
File(widget.path),
|
||||
width: MediaQuery.of(context).size.width - 80,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildDialogTitle(BuildContext context) {
|
||||
return [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'add_certificate_expiry_date'.tr(),
|
||||
style: AppTextStyles.bodyLargeMed,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Assets.images.icons.x.svg(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'please_enter_expiry_date'.tr(),
|
||||
style: AppTextStyles.bodySmallReg.copyWith(color: AppColors.blackGray),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:krow/core/data/static/email_validation_constants.dart';
|
||||
import 'package:krow/features/profile/email_verification/domain/bloc/email_verification_bloc.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@injectable
|
||||
class EmailVerificationService {
|
||||
static const recentLoginRequired = 'requires-recent-login';
|
||||
static const reLoginRequired = 'requires-re-login';
|
||||
static const tokenExpired = 'user-token-expired';
|
||||
|
||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||
String? _currentUserPhone;
|
||||
|
||||
String get currentUserPhone {
|
||||
_currentUserPhone ??= _auth.currentUser?.phoneNumber;
|
||||
|
||||
return _currentUserPhone ?? '';
|
||||
}
|
||||
|
||||
Future<void> sendVerificationEmail({required String email}) async {
|
||||
log('Sending email verification $email');
|
||||
|
||||
await _auth.currentUser?.verifyBeforeUpdateEmail(
|
||||
email,
|
||||
// TODO: Enabling this code will require enabling Firebase Dynamic Links.
|
||||
// ActionCodeSettings(
|
||||
// url: 'https://staging.app.krow.develop.express/emailLinkAuth/',
|
||||
// androidPackageName: dotenv.get('ANDROID_PACKAGE'),
|
||||
// iOSBundleId: dotenv.get('IOS_BUNDLE_ID'),
|
||||
// androidInstallApp: true,
|
||||
// handleCodeInApp: true,
|
||||
// linkDomain: 'krow-staging.firebaseapp.com',
|
||||
// ),
|
||||
);
|
||||
|
||||
final sharedPrefs = await SharedPreferences.getInstance();
|
||||
|
||||
sharedPrefs.setString(
|
||||
EmailValidationConstants.storedEmailKey,
|
||||
email,
|
||||
);
|
||||
}
|
||||
|
||||
bool checkEmailForVerification({required String email}) {
|
||||
final user = _auth.currentUser;
|
||||
|
||||
if (user == null || user.email == null) return false;
|
||||
|
||||
return email == user.email && user.emailVerified;
|
||||
}
|
||||
|
||||
Future<bool> isUserEmailVerified({
|
||||
required String newEmail,
|
||||
}) async {
|
||||
await _auth.currentUser?.reload();
|
||||
final user = _auth.currentUser;
|
||||
|
||||
log(
|
||||
'Current user email: ${user?.email}, '
|
||||
'verified: ${user?.emailVerified}, '
|
||||
'Expected email: $newEmail',
|
||||
);
|
||||
if (user == null) {
|
||||
throw FirebaseAuthException(code: reLoginRequired);
|
||||
}
|
||||
return (user.emailVerified) && user.email == newEmail;
|
||||
}
|
||||
|
||||
ReAuthRequirement isReAuthenticationRequired(String errorCode) {
|
||||
return switch (errorCode) {
|
||||
recentLoginRequired => ReAuthRequirement.recent,
|
||||
reLoginRequired || tokenExpired => ReAuthRequirement.immediate,
|
||||
_ => ReAuthRequirement.none,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/core/application/di/injectable.dart';
|
||||
import 'package:krow/core/data/enums/state_status.dart';
|
||||
import 'package:krow/core/sevices/auth_state_service/auth_service.dart';
|
||||
import 'package:krow/features/profile/email_verification/data/email_verification_service.dart';
|
||||
|
||||
part 'email_verification_event.dart';
|
||||
|
||||
part 'email_verification_state.dart';
|
||||
|
||||
/// This BLoC is currently unfinished.
|
||||
/// We faced an issue with the Firebase email verification process. As a
|
||||
/// security-sensitive action, it requires recent user authentication (seems
|
||||
/// like around 5-10 minutes), so we have to prompt the user to re-login. Only
|
||||
/// after that can the verification email be sent.
|
||||
/// But upon receiving the email and confirming it, another issue arises: the
|
||||
/// current auth token becomes invalid, and the user is silently signed out from
|
||||
/// Firebase. On mobile, it can be solved by integrating a deep-link into the
|
||||
/// verification email, which will allow us to re-authenticate the user with
|
||||
/// an email link credential in the background.
|
||||
/// However, there is a possibility that the user might verify his email on a
|
||||
/// desktop (or any other device), so the link won't be received. In this case,
|
||||
/// we need to prompt the user to re-authenticate yet again. This will probably
|
||||
/// be a bad user experience.
|
||||
class EmailVerificationBloc
|
||||
extends Bloc<EmailVerificationEvent, EmailVerificationState> {
|
||||
EmailVerificationBloc() : super(const EmailVerificationState()) {
|
||||
on<SetVerifiedData>((event, emit) async {
|
||||
emit(state.copyWith(
|
||||
email: event.verifiedEmail,
|
||||
userPhone: event.userPhone,
|
||||
));
|
||||
|
||||
add(const SendVerificationEmail());
|
||||
});
|
||||
|
||||
on<SendVerificationEmail>((event, emit) async {
|
||||
try {
|
||||
await _verificationService.sendVerificationEmail(email: state.email);
|
||||
|
||||
emailCheckTimer?.cancel();
|
||||
emailCheckTimer = Timer.periodic(
|
||||
const Duration(seconds: 3),
|
||||
(_) => add(const CheckEmailStatus()),
|
||||
);
|
||||
} catch (except) {
|
||||
log(
|
||||
'Error in EmailVerificationBloc. On SetVerifiedEmail event',
|
||||
error: except,
|
||||
);
|
||||
|
||||
if (except is FirebaseAuthException) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
reAuthRequirement:
|
||||
_verificationService.isReAuthenticationRequired(except.code),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
emit(state.copyWith(reAuthRequirement: ReAuthRequirement.none));
|
||||
}
|
||||
});
|
||||
|
||||
on<CheckEmailStatus>((event, emit) async {
|
||||
bool isEmailVerified = false;
|
||||
try {
|
||||
isEmailVerified = await _verificationService.isUserEmailVerified(
|
||||
newEmail: state.email,
|
||||
);
|
||||
} catch (except) {
|
||||
log(
|
||||
'Error in EmailVerificationBloc. On CheckEmailStatus event',
|
||||
error: except,
|
||||
);
|
||||
if (except is FirebaseAuthException) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
reAuthRequirement:
|
||||
_verificationService.isReAuthenticationRequired(except.code),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: isEmailVerified ? StateStatus.success : StateStatus.idle,
|
||||
),
|
||||
);
|
||||
|
||||
if (state.status == StateStatus.success) emailCheckTimer?.cancel();
|
||||
});
|
||||
|
||||
on<ResendVerificationEmail>((event, emit) {
|
||||
emit(state.copyWith(reAuthRequirement: ReAuthRequirement.none));
|
||||
|
||||
add(const SendVerificationEmail());
|
||||
});
|
||||
|
||||
on<IncomingVerificationLink>((event, emit) async {
|
||||
try {
|
||||
await getIt<AuthService>().signInWithEmailLink(
|
||||
link: event.verificationLink,
|
||||
);
|
||||
|
||||
emit(state.copyWith(status: StateStatus.success));
|
||||
} catch (except) {
|
||||
log(
|
||||
'Error in EmailVerificationBloc. On IncomingVerificationLink event',
|
||||
error: except,
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == StateStatus.success) emailCheckTimer?.cancel();
|
||||
});
|
||||
}
|
||||
|
||||
final _verificationService = getIt<EmailVerificationService>();
|
||||
Timer? emailCheckTimer;
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
emailCheckTimer?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
part of 'email_verification_bloc.dart';
|
||||
|
||||
@immutable
|
||||
sealed class EmailVerificationEvent {
|
||||
const EmailVerificationEvent();
|
||||
}
|
||||
|
||||
class SetVerifiedData extends EmailVerificationEvent {
|
||||
const SetVerifiedData({required this.verifiedEmail, required this.userPhone});
|
||||
|
||||
final String verifiedEmail;
|
||||
final String userPhone;
|
||||
}
|
||||
|
||||
class SendVerificationEmail extends EmailVerificationEvent {
|
||||
const SendVerificationEmail();
|
||||
}
|
||||
|
||||
class CheckEmailStatus extends EmailVerificationEvent {
|
||||
const CheckEmailStatus();
|
||||
}
|
||||
|
||||
class ResendVerificationEmail extends EmailVerificationEvent {
|
||||
const ResendVerificationEmail();
|
||||
}
|
||||
|
||||
class IncomingVerificationLink extends EmailVerificationEvent {
|
||||
const IncomingVerificationLink({required this.verificationLink});
|
||||
|
||||
final Uri verificationLink;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
part of 'email_verification_bloc.dart';
|
||||
|
||||
@immutable
|
||||
class EmailVerificationState {
|
||||
const EmailVerificationState({
|
||||
this.email = '',
|
||||
this.status = StateStatus.idle,
|
||||
this.reAuthRequirement = ReAuthRequirement.none,
|
||||
this.userPhone = '',
|
||||
});
|
||||
|
||||
final String email;
|
||||
final StateStatus status;
|
||||
final ReAuthRequirement reAuthRequirement;
|
||||
final String userPhone;
|
||||
|
||||
EmailVerificationState copyWith({
|
||||
String? email,
|
||||
StateStatus? status,
|
||||
ReAuthRequirement? reAuthRequirement,
|
||||
String? userPhone,
|
||||
}) {
|
||||
return EmailVerificationState(
|
||||
email: email ?? this.email,
|
||||
status: status ?? this.status,
|
||||
reAuthRequirement: reAuthRequirement ?? this.reAuthRequirement,
|
||||
userPhone: userPhone ?? this.userPhone,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum ReAuthRequirement { none, recent, immediate }
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/core/presentation/widgets/scroll_layout_helper.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
|
||||
import 'package:krow/features/profile/email_verification/domain/bloc/email_verification_bloc.dart';
|
||||
import 'package:krow/features/profile/email_verification/presentation/widgets/bottom_control_button.dart';
|
||||
import 'package:krow/features/profile/email_verification/presentation/widgets/verification_actions_widget.dart';
|
||||
|
||||
@RoutePage()
|
||||
class EmailVerificationScreen extends StatefulWidget
|
||||
implements AutoRouteWrapper {
|
||||
const EmailVerificationScreen({
|
||||
super.key,
|
||||
required this.verifiedEmail,
|
||||
required this.userPhone,
|
||||
});
|
||||
|
||||
final String verifiedEmail;
|
||||
final String userPhone;
|
||||
|
||||
@override
|
||||
State<EmailVerificationScreen> createState() =>
|
||||
_EmailVerificationScreenState();
|
||||
|
||||
@override
|
||||
Widget wrappedRoute(BuildContext context) {
|
||||
return BlocProvider<EmailVerificationBloc>(
|
||||
create: (context) => EmailVerificationBloc()
|
||||
..add(SetVerifiedData(
|
||||
verifiedEmail: verifiedEmail,
|
||||
userPhone: userPhone,
|
||||
)),
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmailVerificationScreenState extends State<EmailVerificationScreen> {
|
||||
StreamSubscription<Uri>? _appLinkSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final context = this.context;
|
||||
_appLinkSubscription = AppLinks().uriLinkStream.listen(
|
||||
(link) {
|
||||
if (!context.mounted) return;
|
||||
|
||||
context
|
||||
.read<EmailVerificationBloc>()
|
||||
.add(IncomingVerificationLink(verificationLink: link));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: KwAppBar(
|
||||
titleText: 'email_verification'.tr(),
|
||||
),
|
||||
body: ScrollLayoutHelper(
|
||||
padding: const EdgeInsets.all(16),
|
||||
upperWidget: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Gap(4),
|
||||
Text(
|
||||
'check_your_email'.tr(),
|
||||
style: AppTextStyles.headingH1,
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'verification_link_sent'
|
||||
.tr(args: [widget.verifiedEmail]),
|
||||
style: AppTextStyles.bodyMediumReg.copyWith(
|
||||
color: AppColors.blackGray,
|
||||
),
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
const Gap(24),
|
||||
const VerificationActionsWidget(),
|
||||
],
|
||||
),
|
||||
lowerWidget: const BottomControlButton(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_appLinkSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/core/application/routing/routes.gr.dart';
|
||||
import 'package:krow/core/data/enums/state_status.dart';
|
||||
import 'package:krow/core/presentation/gen/assets.gen.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/dialogs/kw_dialog.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_loading_overlay.dart';
|
||||
import 'package:krow/features/profile/email_verification/domain/bloc/email_verification_bloc.dart';
|
||||
|
||||
class BottomControlButton extends StatelessWidget {
|
||||
const BottomControlButton({super.key});
|
||||
|
||||
void _listenHandler(BuildContext context, EmailVerificationState state) {
|
||||
if (state.reAuthRequirement == ReAuthRequirement.none) return;
|
||||
|
||||
KwDialog.show(
|
||||
context: context,
|
||||
icon: Assets.images.icons.alertTriangle,
|
||||
state: KwDialogState.negative,
|
||||
barrierDismissible: false,
|
||||
title: 'additional_action_needed'.tr(),
|
||||
message: state.reAuthRequirement == ReAuthRequirement.recent
|
||||
? 'email_verification_security_sensitive'.tr(args: [state.userPhone])
|
||||
: 'unable_to_validate_email_status'.tr(),
|
||||
primaryButtonLabel: 'Continue'.tr(),
|
||||
onPrimaryButtonPressed: (dialogContext) async {
|
||||
final isReAuthenticated = await dialogContext.pushRoute<bool>(
|
||||
PhoneReLoginFlowRoute(userPhone: state.userPhone),
|
||||
);
|
||||
|
||||
if (dialogContext.mounted) Navigator.maybePop(dialogContext);
|
||||
|
||||
if (isReAuthenticated == true &&
|
||||
context.mounted &&
|
||||
state.reAuthRequirement == ReAuthRequirement.recent) {
|
||||
context
|
||||
.read<EmailVerificationBloc>()
|
||||
.add(const ResendVerificationEmail());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<EmailVerificationBloc, EmailVerificationState>(
|
||||
buildWhen: (previous, current) => previous.status != current.status,
|
||||
listenWhen: (previous, current) =>
|
||||
previous.reAuthRequirement != current.reAuthRequirement,
|
||||
listener: _listenHandler,
|
||||
builder: (context, state) {
|
||||
return KwLoadingOverlay(
|
||||
shouldShowLoading: state.status == StateStatus.loading,
|
||||
child: KwButton.primary(
|
||||
label: 'Continue'.tr(),
|
||||
disabled: state.status != StateStatus.success,
|
||||
onPressed: () {
|
||||
context.maybePop(true);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/features/profile/email_verification/domain/bloc/email_verification_bloc.dart';
|
||||
|
||||
class VerificationActionsWidget extends StatefulWidget {
|
||||
const VerificationActionsWidget({super.key});
|
||||
|
||||
@override
|
||||
State<VerificationActionsWidget> createState() =>
|
||||
_VerificationActionsWidgetState();
|
||||
}
|
||||
|
||||
//TODO: Finish this widget incorporating increasing timer on each code resend.
|
||||
class _VerificationActionsWidgetState extends State<VerificationActionsWidget> {
|
||||
double secondsToHold = 1;
|
||||
bool onHold = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenHeight = MediaQuery.sizeOf(context).height;
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: screenHeight / 4.6),
|
||||
child: RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(
|
||||
text: 'didnt_receive'.tr(),
|
||||
style: AppTextStyles.bodyMediumReg
|
||||
.copyWith(color: AppColors.blackGray),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'resend'.tr(),
|
||||
style: AppTextStyles.bodyMediumSmb,
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
context
|
||||
.read<EmailVerificationBloc>()
|
||||
.add(const ResendVerificationEmail());
|
||||
},
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ${'or'.tr()} ',
|
||||
style: AppTextStyles.bodyMediumReg
|
||||
.copyWith(color: AppColors.blackGray),
|
||||
),
|
||||
TextSpan(
|
||||
text: 'contact_support_1'.tr(),
|
||||
style: AppTextStyles.bodyMediumSmb,
|
||||
recognizer: TapGestureRecognizer()..onTap = () {
|
||||
//TODO: Add Contact support action.
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user