Created
May 9, 2026 10:53
-
-
Save elbeicktalat/12880c9a1a375b8beeb85bc17ad4b361 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'dart:async'; | |
| import 'package:equatable/equatable.dart'; | |
| import 'package:firebase_crashlytics/firebase_crashlytics.dart'; | |
| import 'package:flutter_terminal_sdk/flutter_terminal_sdk.dart'; | |
| import 'package:flutter_terminal_sdk/models/card_reader_callbacks.dart'; | |
| import 'package:flutter_terminal_sdk/models/data/TerminalList.dart'; | |
| import 'package:flutter_terminal_sdk/models/data/purchase_response.dart'; | |
| import 'package:flutter_terminal_sdk/models/data/refund_response.dart'; | |
| import 'package:flutter_terminal_sdk/models/nearpay_user_response.dart'; | |
| import 'package:flutter_terminal_sdk/models/purchase_callbacks.dart'; | |
| import 'package:flutter_terminal_sdk/models/refund_callbacks.dart'; | |
| import 'package:flutter_terminal_sdk/models/terminal_connection_response.dart'; | |
| import 'package:flutter_terminal_sdk/models/terminal_response.dart'; | |
| import 'package:flutter_terminal_sdk/models/terminal_sdk_initialization_listener.dart'; | |
| import 'package:hydrated_bloc/hydrated_bloc.dart'; | |
| import 'package:logger/logger.dart'; | |
| import 'package:meta/meta.dart'; | |
| import 'package:raas/src/base/utils/env.dart'; | |
| import 'package:raas/src/base/utils/logging.dart'; | |
| import 'package:raas/src/core/terminal/nearpay/domain/nearpay_error_type.dart'; | |
| part 'nearpay_event.dart'; | |
| part 'nearpay_state.dart'; | |
| class NearpayBloc extends HydratedBloc<NearpayEvent, NearpayState> { | |
| NearpayBloc(this._terminalSdk) : super(const NearpayInitial()) { | |
| on<InitializeNearpayTerminal>(_onInitializeNearpayTerminal); | |
| on<SendNearpayOtp>(_onSendNearpayOtp); | |
| on<VerifyNearpayOtp>(_onVerifyNearpayOtp); | |
| on<NearpayJWTLogin>(_onNearpayJWTLogin); | |
| on<FetchAndConnectToFirstTerminal>(_onFetchAndConnectToFirstTerminal); | |
| on<ConnectToNearpayTerminal>(_onConnectToNearpayTerminal); | |
| on<CreateNearpayPurchase>(_onCreateNearpayPurchase); | |
| on<CreateNearpayRefund>(_onCreateNearpayRefund); | |
| on<LogoutNearpayTerminal>(_onLogoutNearpayTerminal); | |
| } | |
| final FlutterTerminalSdk _terminalSdk; | |
| static final Logger _log = logger('TerminalBloc'); | |
| TerminalModel? _connectedTerminal; | |
| // Create a singleton-like static cache to survive BLoC lifecycle re-builds | |
| static TerminalModel? _cachedConnectedTerminal; | |
| FutureOr<void> _onInitializeNearpayTerminal( | |
| InitializeNearpayTerminal event, | |
| Emitter<NearpayState> emit, | |
| ) async { | |
| _log.i('Starting terminal initialization...'); | |
| emit( | |
| NearpayLoading(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid), | |
| ); | |
| final Completer<void> completer = Completer<void>(); | |
| try { | |
| if (_terminalSdk.isInitialized) { | |
| _log.i('Terminal is already initialized'); | |
| completer.complete(); | |
| } else { | |
| _log.i('Calling _terminalSdk.initialize()...'); | |
| await _terminalSdk.initialize( | |
| environment: Flavor.nearpayEnv, | |
| googleCloudProjectNumber: Flavor.googleProjectNumber, | |
| huaweiSafetyDetectApiKey: '', | |
| country: Country.sa, | |
| initializationListener: TerminalSDKInitializationListener( | |
| onInitializationSuccess: () { | |
| _log.i('SDK initialized successfully via listener'); | |
| if (!completer.isCompleted) completer.complete(); | |
| }, | |
| onInitializationFailure: (String error) { | |
| _log.e('SDK initialization failed via listener: $error'); | |
| if (!completer.isCompleted) completer.completeError(Exception(error)); | |
| }, | |
| ), | |
| ); | |
| } | |
| // Add a timeout just in case the callback never fires | |
| await completer.future.timeout( | |
| const Duration(seconds: 10), | |
| onTimeout: () { | |
| _log.w( | |
| 'Initialization callback timed out after 10 seconds. Assuming success if no error was thrown.', | |
| ); | |
| if (!completer.isCompleted) completer.complete(); | |
| }, | |
| ); | |
| _log.i( | |
| 'Terminal initialization completed. Emit NearpaySuccess so SetupBloc can take over the terminal connection.', | |
| ); | |
| emit( | |
| NearpaySuccess(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid), | |
| ); | |
| } catch (e, stack) { | |
| _log.e('Failed to initialize terminal: $e', error: e, stackTrace: stack); | |
| emit( | |
| NearpayFailure( | |
| errorType: NearpayErrorType.initFailed, | |
| userUUID: state.userUUID, | |
| terminalUUID: state.terminalUUID, | |
| tid: state.tid, | |
| ), | |
| ); | |
| } | |
| } | |
| FutureOr<void> _onSendNearpayOtp(SendNearpayOtp event, Emitter<NearpayState> emit) async { | |
| emit( | |
| NearpayLoading(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid), | |
| ); | |
| try { | |
| await _terminalSdk.sendMobileOtp(event.mobileNumber); | |
| emit( | |
| NearpaySuccess(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid), | |
| ); | |
| } catch (e) { | |
| _log.e('Failed to send OTP: $e'); | |
| emit( | |
| NearpayFailure( | |
| errorType: NearpayErrorType.otpSendFailed, | |
| userUUID: state.userUUID, | |
| terminalUUID: state.terminalUUID, | |
| tid: state.tid, | |
| ), | |
| ); | |
| } | |
| } | |
| FutureOr<void> _onVerifyNearpayOtp(VerifyNearpayOtp event, Emitter<NearpayState> emit) async { | |
| emit( | |
| NearpayLoading(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid), | |
| ); | |
| try { | |
| final NearpayUser user = await _terminalSdk.verifyMobileOtp( | |
| mobileNumber: event.mobileNumber, | |
| code: event.code, | |
| ); | |
| emit( | |
| NearpaySuccess(userUUID: user.userUUID, terminalUUID: state.terminalUUID, tid: state.tid), | |
| ); | |
| } catch (e) { | |
| _log.e('Failed to verify OTP: $e'); | |
| emit( | |
| NearpayFailure( | |
| errorType: NearpayErrorType.otpVerifyFailed, | |
| userUUID: state.userUUID, | |
| terminalUUID: state.terminalUUID, | |
| tid: state.tid, | |
| ), | |
| ); | |
| } | |
| } | |
| FutureOr<void> _onNearpayJWTLogin(NearpayJWTLogin event, Emitter<NearpayState> emit) async { | |
| emit( | |
| NearpayLoading(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid), | |
| ); | |
| try { | |
| final TerminalModel terminalModel = await _terminalSdk.jwtLogin(jwt: event.jwt); | |
| emit( | |
| NearpaySuccess( | |
| userUUID: state.userUUID, | |
| terminalUUID: terminalModel.terminalUUID, | |
| tid: terminalModel.tid, | |
| ), | |
| ); | |
| } catch (e) { | |
| _log.e('Failed to login with JWT: $e'); | |
| emit( | |
| NearpayFailure( | |
| errorType: NearpayErrorType.loginFailed, | |
| userUUID: state.userUUID, | |
| terminalUUID: state.terminalUUID, | |
| tid: state.tid, | |
| ), | |
| ); | |
| } | |
| } | |
| FutureOr<void> _onFetchAndConnectToFirstTerminal( | |
| FetchAndConnectToFirstTerminal event, | |
| Emitter<NearpayState> emit, | |
| ) async { | |
| emit( | |
| NearpayLoading(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid), | |
| ); | |
| if (state.userUUID == null) { | |
| emit( | |
| NearpayFailure( | |
| errorType: NearpayErrorType.missingParameters, | |
| userUUID: state.userUUID, | |
| terminalUUID: state.terminalUUID, | |
| tid: state.tid, | |
| ), | |
| ); | |
| return; | |
| } | |
| try { | |
| final TerminalList terminalList = await _terminalSdk.getTerminalList(state.userUUID!); | |
| if (terminalList.terminals.isEmpty) { | |
| emit( | |
| NearpayFailure( | |
| errorType: NearpayErrorType.noTerminalsFound, | |
| userUUID: state.userUUID, | |
| terminalUUID: state.terminalUUID, | |
| tid: state.tid, | |
| ), | |
| ); | |
| return; | |
| } | |
| final TerminalConnectionModel firstTerminal = terminalList.terminals.first; | |
| // Now connect to this terminal | |
| add( | |
| ConnectToNearpayTerminal( | |
| userUUID: state.userUUID, | |
| terminalUUID: firstTerminal.uuid, | |
| tid: firstTerminal.tid, | |
| ), | |
| ); | |
| } catch (e) { | |
| _log.e('Failed to fetch terminals: $e'); | |
| emit( | |
| NearpayFailure( | |
| errorType: NearpayErrorType.noTerminalsFound, | |
| userUUID: state.userUUID, | |
| terminalUUID: state.terminalUUID, | |
| tid: state.tid, | |
| ), | |
| ); | |
| } | |
| } | |
| FutureOr<void> _onConnectToNearpayTerminal( | |
| ConnectToNearpayTerminal event, | |
| Emitter<NearpayState> emit, | |
| ) async { | |
| emit( | |
| NearpayLoading(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid), | |
| ); | |
| final String? uUUID = event.userUUID ?? state.userUUID; | |
| final String? tUUID = event.terminalUUID ?? state.terminalUUID; | |
| final String? tid = event.tid ?? state.tid; | |
| if (uUUID == null || tUUID == null || tid == null) { | |
| emit( | |
| NearpayFailure( | |
| errorType: NearpayErrorType.missingParameters, | |
| userUUID: state.userUUID, | |
| terminalUUID: state.terminalUUID, | |
| tid: state.tid, | |
| ), | |
| ); | |
| return; | |
| } | |
| try { | |
| _log.i( | |
| 'Calling _terminalSdk.connectTerminal with tid=$tid, userUUID=$uUUID, terminalUUID=$tUUID', | |
| ); | |
| _connectedTerminal = await _terminalSdk | |
| .connectTerminal(tid: tid, userUUID: uUUID, terminalUUID: tUUID) | |
| .timeout( | |
| const Duration(seconds: 10), | |
| onTimeout: () { | |
| _log.w('connectTerminal timed out natively. Falling back to local terminal model.'); | |
| return TerminalModel(terminalUUID: tUUID, tid: tid); | |
| }, | |
| ); | |
| _log.i('Terminal connected successfully: ${_connectedTerminal!.toString()}'); | |
| _cachedConnectedTerminal = _connectedTerminal; | |
| emit( | |
| NearpayConnected( | |
| userUUID: uUUID, | |
| terminalUUID: tUUID, | |
| tid: tid, | |
| terminalModel: _connectedTerminal!, | |
| ), | |
| ); | |
| } catch (e, stack) { | |
| _log.e('Failed to connect terminal: $e', error: e, stackTrace: stack); | |
| emit( | |
| NearpayFailure( | |
| errorType: NearpayErrorType.connectionFailed, | |
| userUUID: state.userUUID, | |
| terminalUUID: state.terminalUUID, | |
| tid: state.tid, | |
| ), | |
| ); | |
| } | |
| } | |
| FutureOr<void> _onCreateNearpayPurchase( | |
| CreateNearpayPurchase event, | |
| Emitter<NearpayState> emit, | |
| ) async { | |
| _log.i('Initiating Nearpay Purchase...'); | |
| // Always use the instance attached to the state if available | |
| if (state is NearpayConnected) { | |
| _connectedTerminal = (state as NearpayConnected).terminalModel; | |
| } | |
| // Fallback to static cache if bloc state stripped it (e.g. hydrated bloc json load) | |
| _connectedTerminal ??= _cachedConnectedTerminal; | |
| // Capture the original connected state so we don't wipe it out! | |
| final NearpayState originalState = state; | |
| emit( | |
| NearpayLoading(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid), | |
| ); | |
| final Completer<PurchaseResponse> completer = Completer<PurchaseResponse>(); | |
| try { | |
| _log.i( | |
| 'Calling _connectedTerminal.purchase with intentUUID: ${event.intentUUID} and amount: ${event.amount}', | |
| ); | |
| // Do not await the native purchase call directly! | |
| // If the SDK implementation blocks the Future until the UI completes, awaiting it might | |
| // cause issues if the Completer is also used, or if the method never returns properly. | |
| _connectedTerminal! | |
| .purchase( | |
| intentUUID: event.intentUUID, | |
| amount: event.amount.cents, | |
| customerReferenceNumber: event.customerReferenceNumber, | |
| callbacks: PurchaseCallbacks( | |
| onTransactionPurchaseCompleted: (PurchaseResponse response) { | |
| _log.i('Purchase completed: ${response.toJson()}'); | |
| if (!completer.isCompleted) { | |
| final status = response.status?.toLowerCase(); | |
| if (status == 'approved') { | |
| completer.complete(response); | |
| } else { | |
| // E.g. 'declined' (insufficient funds, etc) | |
| completer.completeError(Exception(status ?? 'declined')); | |
| } | |
| } | |
| }, | |
| onSendTransactionFailure: (String error) { | |
| _log.e('Purchase failed via callback: $error'); | |
| if (!completer.isCompleted) completer.completeError(Exception(error)); | |
| }, | |
| cardReaderCallbacks: CardReaderCallbacks( | |
| onReaderClosed: () { | |
| _log.i('Purchase closed by user'); | |
| if (!completer.isCompleted) { | |
| completer.completeError(Exception('USER_CANCELLED')); | |
| } | |
| }, | |
| onReaderDismissed: () { | |
| _log.i('Purchase dismissed by user'); | |
| if (!completer.isCompleted) { | |
| completer.completeError(Exception('USER_CANCELLED')); | |
| } | |
| }, | |
| ), | |
| ), | |
| ) | |
| .catchError((e) { | |
| _log.e('Native purchase method threw error immediately: $e'); | |
| if (!completer.isCompleted) completer.completeError(e); | |
| return e; | |
| }); | |
| _log.i('Waiting for completer.future (callbacks)...'); | |
| final PurchaseResponse response = await completer.future; | |
| emit( | |
| NearpayPurchaseSuccess( | |
| response: response, | |
| customerReferenceNumber: event.customerReferenceNumber, | |
| userUUID: originalState.userUUID, | |
| terminalUUID: originalState.terminalUUID, | |
| tid: originalState.tid, | |
| ), | |
| ); | |
| // RESTORE the connected state so we don't lose the terminal object! | |
| emit( | |
| NearpayConnected( | |
| userUUID: originalState.userUUID!, | |
| terminalUUID: originalState.terminalUUID!, | |
| tid: originalState.tid!, | |
| terminalModel: _connectedTerminal!, | |
| ), | |
| ); | |
| } catch (e, s) { | |
| _log.e('Purchase failed or threw an exception: $e', error: e, stackTrace: s); | |
| final errorType = e.toString().contains('USER_CANCELLED') | |
| ? NearpayErrorType.readerDismissed | |
| : NearpayErrorType.purchaseFailed; | |
| emit( | |
| NearpayFailure( | |
| errorType: errorType, | |
| userUUID: originalState.userUUID, | |
| terminalUUID: originalState.terminalUUID, | |
| tid: originalState.tid, | |
| ), | |
| ); | |
| // Attempt to restore connected state even on failure | |
| if (_connectedTerminal != null && originalState.userUUID != null) { | |
| emit( | |
| NearpayConnected( | |
| userUUID: originalState.userUUID!, | |
| terminalUUID: originalState.terminalUUID!, | |
| tid: originalState.tid!, | |
| terminalModel: _connectedTerminal!, | |
| ), | |
| ); | |
| } | |
| unawaited( | |
| FirebaseCrashlytics.instance.recordError( | |
| e, | |
| s, | |
| fatal: true, | |
| reason: 'Nearpay Purchase Failed: $e', | |
| ), | |
| ); | |
| } | |
| } | |
| FutureOr<void> _onCreateNearpayRefund( | |
| CreateNearpayRefund event, | |
| Emitter<NearpayState> emit, | |
| ) async { | |
| // Always use the instance attached to the state if available | |
| if (state is NearpayConnected) { | |
| _connectedTerminal = (state as NearpayConnected).terminalModel; | |
| } | |
| // Fallback to static cache if bloc state stripped it (e.g. hydrated bloc json load) | |
| _connectedTerminal ??= _cachedConnectedTerminal; | |
| // Capture the original connected state so we don't wipe it out! | |
| final NearpayState originalState = state; | |
| emit( | |
| NearpayLoading(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid), | |
| ); | |
| final Completer<RefundResponse> completer = Completer(); | |
| try { | |
| _connectedTerminal! | |
| .refund( | |
| intentUUID: event.intentUUID, | |
| refundUuid: event.refundUuid, | |
| amount: event.amount.cents, | |
| customerReferenceNumber: event.customerReferenceNumber, | |
| callbacks: RefundCallbacks( | |
| onTransactionRefundCompleted: (RefundResponse response) { | |
| if (!completer.isCompleted) completer.complete(response); | |
| }, | |
| onSendTransactionFailure: (String error) { | |
| if (!completer.isCompleted) completer.completeError(Exception(error)); | |
| }, | |
| cardReaderCallbacks: CardReaderCallbacks( | |
| onReaderClosed: () { | |
| _log.i('Refund closed by user'); | |
| if (!completer.isCompleted) { | |
| completer.completeError(Exception('USER_CANCELLED')); | |
| } | |
| }, | |
| onReaderDismissed: () { | |
| _log.i('Refund dismissed by user'); | |
| if (!completer.isCompleted) { | |
| completer.completeError(Exception('USER_CANCELLED')); | |
| } | |
| }, | |
| ), | |
| ), | |
| ) | |
| .then((_) { | |
| _log.i('Native refund method completed execution.'); | |
| Future.delayed(const Duration(milliseconds: 500), () { | |
| if (!completer.isCompleted) { | |
| _log.w('Native method finished but no callback fired. Assuming USER_CANCELLED.'); | |
| completer.completeError(Exception('USER_CANCELLED')); | |
| } | |
| }); | |
| }) | |
| .catchError((e) { | |
| if (!completer.isCompleted) completer.completeError(e); | |
| return e; | |
| }); | |
| _log.i('Waiting for completer.future (callbacks)...'); | |
| final RefundResponse response = await completer.future; | |
| emit( | |
| NearpayRefundSuccess( | |
| response: response, | |
| customerReferenceNumber: event.customerReferenceNumber, | |
| userUUID: originalState.userUUID, | |
| terminalUUID: originalState.terminalUUID, | |
| tid: originalState.tid, | |
| ), | |
| ); | |
| // RESTORE the connected state so we don't lose the terminal object! | |
| emit( | |
| NearpayConnected( | |
| userUUID: originalState.userUUID!, | |
| terminalUUID: originalState.terminalUUID!, | |
| tid: originalState.tid!, | |
| terminalModel: _connectedTerminal!, | |
| ), | |
| ); | |
| } catch (e, s) { | |
| _log.e('Refund failed or threw an exception: $e', error: e, stackTrace: s); | |
| final errorType = e.toString().contains('USER_CANCELLED') | |
| ? NearpayErrorType.readerDismissed | |
| : NearpayErrorType.refundFailed; | |
| emit( | |
| NearpayFailure( | |
| errorType: errorType, | |
| userUUID: originalState.userUUID, | |
| terminalUUID: originalState.terminalUUID, | |
| tid: originalState.tid, | |
| ), | |
| ); | |
| // Attempt to restore connected state even on failure | |
| if (_connectedTerminal != null && originalState.userUUID != null) { | |
| emit( | |
| NearpayConnected( | |
| userUUID: originalState.userUUID!, | |
| terminalUUID: originalState.terminalUUID!, | |
| tid: originalState.tid!, | |
| terminalModel: _connectedTerminal!, | |
| ), | |
| ); | |
| } | |
| unawaited( | |
| FirebaseCrashlytics.instance.recordError(e, s, reason: 'Nearpay Refund Failed: $e'), | |
| ); | |
| } | |
| } | |
| FutureOr<void> _onLogoutNearpayTerminal( | |
| LogoutNearpayTerminal event, | |
| Emitter<NearpayState> emit, | |
| ) async { | |
| emit( | |
| NearpayLoading(userUUID: state.userUUID, terminalUUID: state.terminalUUID, tid: state.tid), | |
| ); | |
| try { | |
| if (state.userUUID != null) { | |
| await _terminalSdk.logout(userUUID: state.userUUID!); | |
| } | |
| _connectedTerminal = null; | |
| _cachedConnectedTerminal = null; | |
| emit(const NearpayInitial()); | |
| } catch (e) { | |
| _log.e('Logout failed: $e'); | |
| emit( | |
| NearpayFailure( | |
| errorType: NearpayErrorType.logoutFailed, | |
| userUUID: state.userUUID, | |
| terminalUUID: state.terminalUUID, | |
| tid: state.tid, | |
| ), | |
| ); | |
| } | |
| } | |
| @override | |
| NearpayState? fromJson(Map<String, dynamic> json) { | |
| try { | |
| final userUUID = json['userUUID'] as String?; | |
| final terminalUUID = json['terminalUUID'] as String?; | |
| final tid = json['tid'] as String?; | |
| if (userUUID != null && terminalUUID != null && tid != null) { | |
| // if we have a cached terminal, use it and don't try to connect again | |
| if (_cachedConnectedTerminal != null) { | |
| _connectedTerminal = _cachedConnectedTerminal; | |
| return NearpayConnected( | |
| userUUID: userUUID, | |
| terminalUUID: terminalUUID, | |
| tid: tid, | |
| terminalModel: _connectedTerminal!, | |
| ); | |
| } | |
| // This tells the UI: "I know who you are, but I still need to connect to the native driver." | |
| return NearpaySuccess(userUUID: userUUID, terminalUUID: terminalUUID, tid: tid); | |
| } | |
| return const NearpayInitial(); | |
| } catch (_) { | |
| return null; | |
| } | |
| } | |
| @override | |
| Map<String, dynamic>? toJson(NearpayState state) { | |
| return {'userUUID': state.userUUID, 'terminalUUID': state.terminalUUID, 'tid': state.tid}; | |
| } | |
| } | |
| extension on double { | |
| /// Converts a double to cents. | |
| int get cents => (this * 100).round(); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| part of 'nearpay_bloc.dart'; | |
| @immutable | |
| sealed class NearpayEvent { | |
| const NearpayEvent(); | |
| } | |
| class InitializeNearpayTerminal extends NearpayEvent {} | |
| class SendNearpayOtp extends NearpayEvent { | |
| const SendNearpayOtp({required this.mobileNumber}); | |
| final String mobileNumber; | |
| } | |
| class VerifyNearpayOtp extends NearpayEvent { | |
| const VerifyNearpayOtp({required this.mobileNumber, required this.code}); | |
| final String mobileNumber; | |
| final String code; | |
| } | |
| class NearpayJWTLogin extends NearpayEvent { | |
| const NearpayJWTLogin({required this.jwt}); | |
| final String jwt; | |
| } | |
| class FetchAndConnectToFirstTerminal extends NearpayEvent { | |
| const FetchAndConnectToFirstTerminal(); | |
| } | |
| class ConnectToNearpayTerminal extends NearpayEvent { | |
| const ConnectToNearpayTerminal({this.userUUID, this.terminalUUID, this.tid}); | |
| final String? userUUID; | |
| final String? terminalUUID; | |
| final String? tid; | |
| } | |
| class CreateNearpayPurchase extends NearpayEvent { | |
| const CreateNearpayPurchase({ | |
| required this.amount, | |
| required this.intentUUID, | |
| required this.customerReferenceNumber, | |
| }); | |
| final double amount; | |
| final String intentUUID; | |
| final String customerReferenceNumber; | |
| } | |
| class CreateNearpayRefund extends NearpayEvent { | |
| const CreateNearpayRefund({ | |
| required this.intentUUID, | |
| required this.refundUuid, | |
| required this.amount, | |
| required this.customerReferenceNumber, | |
| }); | |
| final String intentUUID; | |
| final String refundUuid; | |
| final double amount; | |
| final String customerReferenceNumber; | |
| } | |
| class LogoutNearpayTerminal extends NearpayEvent {} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| part of 'nearpay_bloc.dart'; | |
| @immutable | |
| sealed class NearpayState extends Equatable { | |
| const NearpayState({this.userUUID, this.terminalUUID, this.tid}); | |
| final String? userUUID; | |
| final String? terminalUUID; | |
| final String? tid; | |
| @override | |
| List<Object?> get props => [userUUID, terminalUUID, tid]; | |
| } | |
| class NearpayInitial extends NearpayState { | |
| const NearpayInitial({super.userUUID, super.terminalUUID, super.tid}); | |
| } | |
| class NearpayLoading extends NearpayState { | |
| const NearpayLoading({super.userUUID, super.terminalUUID, super.tid}); | |
| @override | |
| List<Object?> get props => [...super.props]; | |
| } | |
| class NearpayConnected extends NearpayState { | |
| const NearpayConnected({ | |
| required String userUUID, | |
| required String terminalUUID, | |
| required String tid, | |
| required this.terminalModel, | |
| }) : super(userUUID: userUUID, terminalUUID: terminalUUID, tid: tid); | |
| final TerminalModel terminalModel; | |
| @override | |
| List<Object?> get props => [...super.props, terminalModel]; | |
| } | |
| class NearpayFailure extends NearpayState { | |
| const NearpayFailure({required this.errorType, super.userUUID, super.terminalUUID, super.tid}); | |
| final NearpayErrorType errorType; | |
| @override | |
| List<Object?> get props => [...super.props, errorType]; | |
| } | |
| class NearpaySuccess extends NearpayState { | |
| const NearpaySuccess({super.userUUID, super.terminalUUID, super.tid}); | |
| @override | |
| List<Object?> get props => [...super.props]; | |
| } | |
| class NearpayPurchaseSuccess extends NearpaySuccess { | |
| const NearpayPurchaseSuccess({ | |
| required this.response, | |
| required this.customerReferenceNumber, | |
| super.userUUID, | |
| super.terminalUUID, | |
| super.tid, | |
| }); | |
| final PurchaseResponse response; | |
| final String customerReferenceNumber; | |
| @override | |
| List<Object?> get props => [...super.props, response, customerReferenceNumber]; | |
| } | |
| class NearpayRefundSuccess extends NearpaySuccess { | |
| const NearpayRefundSuccess({ | |
| required this.response, | |
| required this.customerReferenceNumber, | |
| super.userUUID, | |
| super.terminalUUID, | |
| super.tid, | |
| }); | |
| final RefundResponse response; | |
| final String customerReferenceNumber; | |
| @override | |
| List<Object?> get props => [...super.props, response, customerReferenceNumber]; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'package:flutter_bloc/flutter_bloc.dart'; | |
| import 'package:flutter_terminal_sdk/models/data/purchase_response.dart'; | |
| import 'package:fpdart/fpdart.dart'; | |
| import 'package:raas/src/core/error/failures.dart'; | |
| import 'package:raas/src/core/terminal/nearpay/bloc/nearpay_bloc.dart'; | |
| import 'package:raas/src/core/terminal/nearpay/domain/nearpay_error_type.dart'; | |
| import 'package:raas/src/core/terminal/nearpay/domain/nearpay_repository.dart'; | |
| import 'package:uuid/uuid.dart'; | |
| /// A mixin to cleanly encapsulate Terminal Payment intents without polluting | |
| /// the concrete bloc's state. Uses composition over inheritance to keep the | |
| /// class hierarchy flexible. | |
| mixin TerminalPaymentMixin<Event, State> on Bloc<Event, State> { | |
| String? _intentUUID; | |
| String? _customerReferenceNumber; | |
| double? _lockedAmount; | |
| /// Executes a terminal purchase safely. | |
| /// Generates and persists intent identifiers internally, ensuring they are | |
| /// only created once per transaction lifecycle, unless the amount changes. | |
| Future<Either<Failure, (PurchaseResponse, String)>> executeTerminalPurchase({ | |
| required double amount, | |
| required NearpayBloc nearpayBloc, | |
| required NearpayRepository nearpayRepository, | |
| }) async { | |
| // CRITICAL FIX: If the user cancels the terminal, adds another item to the cart, | |
| // and tries to pay again, the amount has changed! We MUST invalidate the old intent | |
| // so the backend and the terminal are perfectly in sync with the new amount. | |
| if (_lockedAmount != null && _lockedAmount != amount) { | |
| clearTerminalIntent(); | |
| } | |
| if (_intentUUID == null || _customerReferenceNumber == null) { | |
| _intentUUID = const Uuid().v4(); | |
| _lockedAmount = amount; | |
| try { | |
| _customerReferenceNumber = await nearpayRepository.initPurchase( | |
| intentUUID: _intentUUID!, | |
| amount: amount, | |
| ); | |
| } catch (e) { | |
| clearTerminalIntent(); // Clear on failure to prevent dangling corrupt state | |
| return Left( | |
| TerminalFailure( | |
| NearpayErrorType.purchaseFailed, | |
| 'Failed to initialize terminal purchase on server: $e', | |
| ), | |
| ); | |
| } | |
| } | |
| nearpayBloc.add( | |
| CreateNearpayPurchase( | |
| amount: amount, | |
| intentUUID: _intentUUID!, | |
| customerReferenceNumber: _customerReferenceNumber!, | |
| ), | |
| ); | |
| final NearpayState resultState = await nearpayBloc.stream.firstWhere( | |
| (s) => s is NearpayPurchaseSuccess || s is NearpayFailure, | |
| ); | |
| if (resultState is NearpayPurchaseSuccess) { | |
| return Right((resultState.response, resultState.customerReferenceNumber)); | |
| } else if (resultState is NearpayFailure) { | |
| return Left(TerminalFailure(resultState.errorType)); | |
| } | |
| return const Left( | |
| TerminalFailure(NearpayErrorType.purchaseFailed, 'Unexpected terminal state'), | |
| ); | |
| } | |
| /// Clears the internal terminal intent. | |
| /// Call this when the cart or transaction is deliberately destroyed or switched. | |
| void clearTerminalIntent() { | |
| _intentUUID = null; | |
| _customerReferenceNumber = null; | |
| _lockedAmount = null; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'package:flutter_bloc/flutter_bloc.dart'; | |
| import 'package:flutter_terminal_sdk/models/data/refund_response.dart'; | |
| import 'package:fpdart/fpdart.dart'; | |
| import 'package:raas/src/core/error/failures.dart'; | |
| import 'package:raas/src/core/terminal/nearpay/bloc/nearpay_bloc.dart'; | |
| import 'package:raas/src/core/terminal/nearpay/domain/nearpay_error_type.dart'; | |
| import 'package:raas/src/core/terminal/nearpay/domain/nearpay_repository.dart'; | |
| import 'package:uuid/uuid.dart'; | |
| /// A mixin to cleanly encapsulate Terminal Refund intents without polluting | |
| /// the concrete bloc's state. Uses composition over inheritance to keep the | |
| /// class hierarchy flexible. | |
| mixin TerminalRefundMixin<Event, State> on Bloc<Event, State> { | |
| String? _refundUUID; | |
| String? _originalIntentUUID; | |
| String? _customerReferenceNumber; | |
| double? _lockedAmount; | |
| /// Executes a terminal refund safely. | |
| /// Generates and persists intent identifiers internally, ensuring they are | |
| /// only created once per transaction lifecycle, unless the amount changes. | |
| Future<Either<Failure, (RefundResponse, String)>> executeTerminalRefund({ | |
| required double amount, | |
| required String originalIntentUUID, | |
| required NearpayBloc nearpayBloc, | |
| required NearpayRepository nearpayRepository, | |
| }) async { | |
| // CRITICAL FIX: If the user cancels the terminal, changes amount, | |
| // and tries to refund again, the amount has changed! We MUST invalidate the old intent | |
| // so the backend and the terminal are perfectly in sync with the new amount. | |
| if (_lockedAmount != null && _lockedAmount != amount) { | |
| clearTerminalRefundIntent(); | |
| } | |
| if (_refundUUID == null || | |
| _originalIntentUUID != originalIntentUUID || | |
| _customerReferenceNumber == null) { | |
| _refundUUID = const Uuid().v4(); | |
| _originalIntentUUID = originalIntentUUID; | |
| _lockedAmount = amount; | |
| try { | |
| _customerReferenceNumber = await nearpayRepository.initRefund( | |
| refundUUID: _refundUUID!, | |
| originalIntentUUID: _originalIntentUUID!, | |
| amount: amount, | |
| ); | |
| } catch (e) { | |
| clearTerminalRefundIntent(); // Clear on failure to prevent dangling corrupt state | |
| return Left( | |
| TerminalFailure( | |
| NearpayErrorType.refundFailed, | |
| 'Failed to initialize terminal refund on server: $e', | |
| ), | |
| ); | |
| } | |
| } | |
| nearpayBloc.add( | |
| CreateNearpayRefund( | |
| amount: amount, | |
| intentUUID: _originalIntentUUID!, | |
| refundUuid: _refundUUID!, | |
| customerReferenceNumber: _customerReferenceNumber!, | |
| ), | |
| ); | |
| final resultState = await nearpayBloc.stream.firstWhere( | |
| (s) => s is NearpayRefundSuccess || s is NearpayFailure, | |
| ); | |
| if (resultState is NearpayRefundSuccess) { | |
| return Right((resultState.response, resultState.customerReferenceNumber)); | |
| } else if (resultState is NearpayFailure) { | |
| return Left(TerminalFailure(resultState.errorType)); | |
| } | |
| return const Left(TerminalFailure(NearpayErrorType.refundFailed, 'Unexpected terminal state')); | |
| } | |
| /// Clears the internal terminal refund intent. | |
| /// Call this when the refund operation is deliberately destroyed or switched. | |
| void clearTerminalRefundIntent() { | |
| _refundUUID = null; | |
| _originalIntentUUID = null; | |
| _customerReferenceNumber = null; | |
| _lockedAmount = null; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment