feat: Implement bank account management feature with UI, BLoC integration, and repository setup

This commit is contained in:
Achintha Isuru
2026-01-24 22:36:29 -05:00
parent f035ab8b6c
commit e6e2783a5a
25 changed files with 766 additions and 9 deletions

View File

@@ -22,8 +22,8 @@ extension HomeNavigator on IModularNavigator {
}
/// Navigates to the payments page.
void pushPayments() {
pushNamed('/payments');
void navigateToPayments() {
navigate('/worker-main/payments');
}
/// Navigates to the shifts listing.

View File

@@ -131,7 +131,7 @@ class WorkerHomePage extends StatelessWidget {
child: QuickActionItem(
icon: LucideIcons.dollarSign,
label: quickI18n.earnings,
onTap: () => Modular.to.pushPayments(),
onTap: () => Modular.to.navigateToPayments(),
),
),
],

View File

@@ -16,7 +16,7 @@ class PendingPaymentCard extends StatelessWidget {
Widget build(BuildContext context) {
final pendingI18n = t.staff.home.pending_payment;
return GestureDetector(
onTap: () => Modular.to.pushPayments(),
onTap: () => Modular.to.navigateToPayments(),
child: Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(

View File

@@ -58,7 +58,7 @@ extension ProfileNavigator on IModularNavigator {
/// Navigates to the bank account page.
void pushBankAccount() {
pushNamed('/bank-account');
pushNamed('../bank-account');
}
/// Navigates to the timecard page.

View File

@@ -186,7 +186,7 @@ class StaffProfilePage extends StatelessWidget {
ProfileMenuItem(
icon: UiIcons.creditCard,
label: i18n.menu_items.payments,
onTap: () => Modular.to.navigate('/payments'),
onTap: () => Modular.to.navigate('/worker-main/payments'),
),
ProfileMenuItem(
icon: UiIcons.clock,

View File

@@ -0,0 +1,77 @@
// ignore_for_file: implementation_imports
import 'package:firebase_auth/firebase_auth.dart' as auth;
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:krow_data_connect/src/dataconnect_generated/generated.dart';
import '../../domain/repositories/bank_account_repository.dart';
/// Implementation of [BankAccountRepository].
class BankAccountRepositoryImpl implements BankAccountRepository {
final ExampleConnector _connector;
final auth.FirebaseAuth _auth;
BankAccountRepositoryImpl({
ExampleConnector? connector,
auth.FirebaseAuth? firebaseAuth,
}) : _connector = connector ?? ExampleConnector.instance,
_auth = firebaseAuth ?? auth.FirebaseAuth.instance;
@override
Future<List<BankAccount>> getAccounts() async {
final auth.User? user = _auth.currentUser;
if (user == null) throw Exception('User not authenticated');
final QueryResult<GetAccountsByOwnerIdData, GetAccountsByOwnerIdVariables> result = await _connector.getAccountsByOwnerId(ownerId: user.uid).execute();
return result.data.accounts.map((GetAccountsByOwnerIdAccounts account) {
return BankAccount(
id: account.id,
userId: account.ownerId,
bankName: account.bank,
accountNumber: account.last4, // Using last4 as account number representation for now
last4: account.last4,
accountName: '', // Not returned by API
type: _mapAccountType(account.type),
isPrimary: account.isPrimary ?? false,
);
}).toList();
}
@override
Future<void> addAccount(BankAccount account) async {
final auth.User? user = _auth.currentUser;
if (user == null) throw Exception('User not authenticated');
await _connector.createAccount(
bank: account.bankName,
type: _mapDomainType(account.type),
last4: account.last4 ?? account.accountNumber.substring(account.accountNumber.length - 4),
ownerId: user.uid,
).isPrimary(account.isPrimary).execute();
}
BankAccountType _mapAccountType(EnumValue<AccountType> type) {
if (type is Known<AccountType>) {
switch (type.value) {
case AccountType.CHECKING:
return BankAccountType.checking;
case AccountType.SAVINGS:
return BankAccountType.savings;
}
}
return BankAccountType.other;
}
AccountType _mapDomainType(BankAccountType type) {
switch (type) {
case BankAccountType.checking:
return AccountType.CHECKING;
case BankAccountType.savings:
return AccountType.SAVINGS;
default:
return AccountType.CHECKING; // Default fallback
}
}
}

View File

@@ -0,0 +1,16 @@
import 'package:equatable/equatable.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
/// Arguments for adding a bank account.
class AddBankAccountParams extends UseCaseArgument with EquatableMixin {
final BankAccount account;
const AddBankAccountParams({required this.account});
@override
List<Object?> get props => [account];
@override
bool? get stringify => true;
}

View File

@@ -0,0 +1,10 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for managing bank accounts.
abstract class BankAccountRepository {
/// Fetches the list of bank accounts for the current user.
Future<List<BankAccount>> getAccounts();
/// adds a new bank account.
Future<void> addAccount(BankAccount account);
}

View File

@@ -0,0 +1,15 @@
import 'package:krow_core/core.dart';
import '../repositories/bank_account_repository.dart';
import '../arguments/add_bank_account_params.dart';
/// Use case to add a bank account.
class AddBankAccountUseCase implements UseCase<AddBankAccountParams, void> {
final BankAccountRepository _repository;
AddBankAccountUseCase(this._repository);
@override
Future<void> call(AddBankAccountParams params) {
return _repository.addAccount(params.account);
}
}

View File

@@ -0,0 +1,15 @@
import 'package:krow_core/core.dart'; // For UseCase
import 'package:krow_domain/krow_domain.dart';
import '../repositories/bank_account_repository.dart';
/// Use case to fetch bank accounts.
class GetBankAccountsUseCase implements NoInputUseCase<List<BankAccount>> {
final BankAccountRepository _repository;
GetBankAccountsUseCase(this._repository);
@override
Future<List<BankAccount>> call() {
return _repository.getAccounts();
}
}

View File

@@ -0,0 +1,75 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/arguments/add_bank_account_params.dart';
import '../../domain/usecases/add_bank_account_usecase.dart';
import '../../domain/usecases/get_bank_accounts_usecase.dart';
import 'bank_account_state.dart';
class BankAccountCubit extends Cubit<BankAccountState> {
final GetBankAccountsUseCase _getBankAccountsUseCase;
final AddBankAccountUseCase _addBankAccountUseCase;
BankAccountCubit({
required GetBankAccountsUseCase getBankAccountsUseCase,
required AddBankAccountUseCase addBankAccountUseCase,
}) : _getBankAccountsUseCase = getBankAccountsUseCase,
_addBankAccountUseCase = addBankAccountUseCase,
super(const BankAccountState());
Future<void> loadAccounts() async {
emit(state.copyWith(status: BankAccountStatus.loading));
try {
final accounts = await _getBankAccountsUseCase();
emit(state.copyWith(
status: BankAccountStatus.loaded,
accounts: accounts,
));
} catch (e) {
emit(state.copyWith(
status: BankAccountStatus.error,
errorMessage: e.toString(),
));
}
}
void toggleForm(bool show) {
emit(state.copyWith(showForm: show));
}
Future<void> addAccount({
required String routingNumber,
required String accountNumber,
required String type,
}) async {
emit(state.copyWith(status: BankAccountStatus.loading));
// Create domain entity
final newAccount = BankAccount(
id: '', // Generated by server usually
userId: '', // Handled by Repo/Auth
bankName: 'New Bank', // Mock
accountNumber: accountNumber,
accountName: '',
type: type == 'CHECKING' ? BankAccountType.checking : BankAccountType.savings,
last4: accountNumber.length > 4 ? accountNumber.substring(accountNumber.length - 4) : accountNumber,
isPrimary: false,
);
try {
await _addBankAccountUseCase(AddBankAccountParams(account: newAccount));
// Re-fetch to get latest state including server-generated IDs
await loadAccounts();
emit(state.copyWith(
showForm: false, // Close form on success
));
} catch (e) {
emit(state.copyWith(
status: BankAccountStatus.error,
errorMessage: e.toString(),
));
}
}
}

View File

@@ -0,0 +1,35 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
enum BankAccountStatus { initial, loading, loaded, error }
class BankAccountState extends Equatable {
final BankAccountStatus status;
final List<BankAccount> accounts;
final String? errorMessage;
final bool showForm;
const BankAccountState({
this.status = BankAccountStatus.initial,
this.accounts = const [],
this.errorMessage,
this.showForm = false,
});
BankAccountState copyWith({
BankAccountStatus? status,
List<BankAccount>? accounts,
String? errorMessage,
bool? showForm,
}) {
return BankAccountState(
status: status ?? this.status,
accounts: accounts ?? this.accounts,
errorMessage: errorMessage,
showForm: showForm ?? this.showForm,
);
}
@override
List<Object?> get props => [status, accounts, errorMessage, showForm];
}

View File

@@ -0,0 +1,7 @@
import 'package:flutter_modular/flutter_modular.dart';
extension StaffBankAccountNavigator on IModularNavigator {
void popPage() {
pop();
}
}

View File

@@ -0,0 +1,233 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:design_system/design_system.dart';
import 'package:core_localization/core_localization.dart';
import 'package:krow_domain/krow_domain.dart';
// ignore: depend_on_referenced_packages
import '../blocs/bank_account_cubit.dart';
import '../blocs/bank_account_state.dart';
import '../navigation/staff_bank_account_navigator.dart';
import '../widgets/add_account_form.dart';
class BankAccountPage extends StatelessWidget {
const BankAccountPage({super.key});
@override
Widget build(BuildContext context) {
final BankAccountCubit cubit = Modular.get<BankAccountCubit>();
// Load accounts initially
if (cubit.state.status == BankAccountStatus.initial) {
cubit.loadAccounts();
}
// final t = AppTranslation.current; // Replaced
final Translations t = Translations.of(context);
final dynamic strings = t.staff.profile.bank_account_page;
return Scaffold(
backgroundColor: UiColors.background,
appBar: AppBar(
backgroundColor: UiColors.background, // Was surface
elevation: 0,
leading: IconButton(
icon: const Icon(UiIcons.arrowLeft, color: UiColors.textSecondary),
onPressed: () => Modular.to.popPage(),
),
title: Text(
strings.title,
style: UiTypography.headline3m.copyWith(color: UiColors.textPrimary),
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1.0),
child: Container(color: UiColors.border, height: 1.0),
),
),
body: BlocBuilder<BankAccountCubit, BankAccountState>(
bloc: cubit,
builder: (BuildContext context, BankAccountState state) {
if (state.status == BankAccountStatus.loading && state.accounts.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == BankAccountStatus.error) {
return Center(child: Text(state.errorMessage ?? 'Error'));
}
return Column(
children: <Widget>[
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_buildSecurityNotice(strings),
const SizedBox(height: UiConstants.space6),
Text(
strings.linked_accounts,
style: UiTypography.headline4m.copyWith(color: UiColors.textPrimary),
),
const SizedBox(height: UiConstants.space3),
...state.accounts.map((BankAccount a) => _buildAccountCard(a, strings)), // Added type
if (state.showForm) ...<Widget>[
const SizedBox(height: UiConstants.space6),
AddAccountForm(
strings: strings,
onSubmit: (String routing, String account, String type) { // Added types
cubit.addAccount(
routingNumber: routing,
accountNumber: account,
type: type);
},
),
],
// Add extra padding at bottom
const SizedBox(height: 80),
],
),
),
),
if (!state.showForm)
Container(
padding: const EdgeInsets.all(UiConstants.space5),
decoration: const BoxDecoration(
color: UiColors.background, // Was surface
border: Border(top: BorderSide(color: UiColors.border)),
),
child: SafeArea(
child: UiButton.primary(
text: strings.add_account,
leadingIcon: UiIcons.add,
onPressed: () => cubit.toggleForm(true),
fullWidth: true,
),
),
),
],
);
},
),
);
}
Widget _buildSecurityNotice(dynamic strings) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.primary.withOpacity(0.08),
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Icon(UiIcons.shield, color: UiColors.primary, size: 20),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
strings.secure_title,
style: UiTypography.body2r.copyWith( // Was body2
fontWeight: FontWeight.w500,
color: UiColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
strings.secure_subtitle,
style: UiTypography.body3r.copyWith(color: UiColors.textSecondary), // Was bodySmall
),
],
),
),
],
),
);
}
Widget _buildAccountCard(BankAccount account, dynamic strings) {
final bool isPrimary = account.isPrimary;
const Color primaryColor = UiColors.primary;
return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.bgPopup, // Was surface, using bgPopup (white) for card
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(
color: isPrimary ? primaryColor : UiColors.border,
width: isPrimary ? 2 : 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: <Widget>[
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
child: const Center(
child: Icon(
UiIcons.building,
color: primaryColor,
size: 24,
),
),
),
const SizedBox(width: UiConstants.space3),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
account.bankName,
style: UiTypography.body2r.copyWith( // Was body2
fontWeight: FontWeight.w500,
color: UiColors.textPrimary,
),
),
Text(
strings.account_ending(last4: account.last4),
style: UiTypography.body2r.copyWith( // Was body2
color: UiColors.textSecondary,
),
),
],
),
],
),
if (isPrimary)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: primaryColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: <Widget>[
const Icon(UiIcons.check, size: 12, color: primaryColor),
const SizedBox(width: 4),
Text(
strings.primary,
style: UiTypography.body3r.copyWith( // Was bodySmall
fontWeight: FontWeight.w500,
color: primaryColor,
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:design_system/design_system.dart';
import '../blocs/bank_account_cubit.dart';
class AddAccountForm extends StatefulWidget {
final dynamic strings;
final Function(String routing, String account, String type) onSubmit;
const AddAccountForm({super.key, required this.strings, required this.onSubmit});
@override
State<AddAccountForm> createState() => _AddAccountFormState();
}
class _AddAccountFormState extends State<AddAccountForm> {
final TextEditingController _routingController = TextEditingController();
final TextEditingController _accountController = TextEditingController();
String _selectedType = 'CHECKING';
@override
void dispose() {
_routingController.dispose();
_accountController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.bgPopup, // Was surface
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(color: UiColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
widget.strings.add_new_account,
style: UiTypography.headline4m.copyWith(color: UiColors.textPrimary), // Was header4
),
const SizedBox(height: UiConstants.space4),
UiTextField(
label: widget.strings.routing_number,
hintText: widget.strings.routing_hint,
controller: _routingController,
keyboardType: TextInputType.number,
),
const SizedBox(height: UiConstants.space4),
UiTextField(
label: widget.strings.account_number,
hintText: widget.strings.account_hint,
controller: _accountController,
keyboardType: TextInputType.number,
),
const SizedBox(height: UiConstants.space4),
Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space2),
child: Text(
widget.strings.account_type,
style: UiTypography.body2r.copyWith( // Was body2
color: UiColors.textSecondary, fontWeight: FontWeight.w500),
),
),
Row(
children: <Widget>[
Expanded(
child: _buildTypeButton('CHECKING', widget.strings.checking)),
const SizedBox(width: UiConstants.space2),
Expanded(
child: _buildTypeButton('SAVINGS', widget.strings.savings)),
],
),
const SizedBox(height: UiConstants.space6),
Row(
children: <Widget>[
Expanded(
child: UiButton.text(
text: widget.strings.cancel,
onPressed: () {
Modular.get<BankAccountCubit>().toggleForm(false);
},
),
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: UiButton.primary(
text: widget.strings.save,
onPressed: () {
widget.onSubmit(
_routingController.text,
_accountController.text,
_selectedType,
);
},
),
),
],
),
],
),
);
}
Widget _buildTypeButton(String type, String label) {
final bool isSelected = _selectedType == type;
return GestureDetector(
onTap: () => setState(() => _selectedType = type),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: isSelected
? UiColors.primary.withOpacity(0.05)
: UiColors.bgPopup, // Was surface
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(
color: isSelected ? UiColors.primary : UiColors.border,
width: isSelected ? 2 : 1,
),
),
child: Center(
child: Text(
label,
style: UiTypography.body2r.copyWith( // Was body2
fontWeight: FontWeight.w600,
color: isSelected ? UiColors.primary : UiColors.textSecondary,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'package:staff_bank_account/src/data/repositories/bank_account_repository_impl.dart';
import 'presentation/blocs/bank_account_cubit.dart';
import 'presentation/pages/bank_account_page.dart';
import 'domain/repositories/bank_account_repository.dart';
import 'domain/usecases/get_bank_accounts_usecase.dart';
import 'domain/usecases/add_bank_account_usecase.dart';
class StaffBankAccountModule extends Module {
@override
void binds(Injector i) {
// Repositories
i.add<BankAccountRepository>(BankAccountRepositoryImpl.new);
// Use Cases
i.add(GetBankAccountsUseCase.new);
i.add(AddBankAccountUseCase.new);
// Blocs
i.addSingleton<BankAccountCubit>(
() => BankAccountCubit(
getBankAccountsUseCase: i.get<GetBankAccountsUseCase>(),
addBankAccountUseCase: i.get<AddBankAccountUseCase>(),
),
);
}
@override
void routes(RouteManager r) {
r.child('/', child: (_) => const BankAccountPage());
}
}

View File

@@ -0,0 +1,3 @@
library staff_bank_account;
export 'src/staff_bank_account_module.dart';

View File

@@ -0,0 +1,35 @@
name: staff_bank_account
description: Staff Bank Account feature.
version: 0.0.1
publish_to: none
resolution: workspace
environment:
sdk: '>=3.10.0 <4.0.0'
flutter: ">=3.0.0"
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.1.0
bloc: ^8.1.0
flutter_modular: ^6.3.0
equatable: ^2.0.5
lucide_icons: ^0.257.0
# Architecture Packages
design_system:
path: ../../../../../design_system
core_localization:
path: ../../../../../core_localization
krow_core:
path: ../../../../../core
krow_domain:
path: ../../../../../domain
krow_data_connect:
path: ../../../../../data_connect
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0

View File

@@ -5,6 +5,7 @@ import 'package:staff_profile/staff_profile.dart';
import 'package:staff_profile_info/staff_profile_info.dart';
import 'package:staff_emergency_contact/staff_emergency_contact.dart';
import 'package:staff_profile_experience/staff_profile_experience.dart';
import 'package:staff_bank_account/staff_bank_account.dart';
import 'package:staff_main/src/presentation/blocs/staff_main_cubit.dart';
import 'package:staff_main/src/presentation/constants/staff_main_routes.dart';
@@ -51,5 +52,6 @@ class StaffMainModule extends Module {
r.module('/onboarding', module: StaffProfileInfoModule());
r.module('/emergency-contact', module: StaffEmergencyContactModule());
r.module('/experience', module: StaffProfileExperienceModule());
r.module('/bank-account', module: StaffBankAccountModule());
}
}

View File

@@ -33,6 +33,8 @@ dependencies:
path: ../profile_sections/onboarding/emergency_contact
staff_profile_experience:
path: ../profile_sections/onboarding/experience
staff_bank_account:
path: ../profile_sections/finances/staff_bank_account
# staff_shifts:
# path: ../shifts
# staff_payments: