Phase 3.7: paid pairing flow + returning chat + extension flip
- Backend: payment_sessions + pairing_failures tables; payment.service.js and pairing-failure.service.js (new); rewritten pairing.service.js (payment-gated blast + targeted "Curhat lagi" + cancel + fallback); rewritten extension.service.js (data-driven auto-approve with offline safeguard, charge-at-approval); pricing.service.js (extension tiers without free trial); mitra-status.service.js (countAvailableMitras cached path); 60s sweeper for stale payment sessions - Backend routes: client.payment.routes, client.mitra-availability.routes, internal/failed-pairings.routes; client.chat.routes rewritten for payment-gated start + /returning + /cancel + /fallback-to-blast; internal/config.routes adds 4 new keys with Valkey invalidate publish - client_app: mitra-availability poll, payment screen + notifier, pairing notifier rewrite (PairingTargetedWaiting + PairingFailed states), targeted-waiting overlay + bestie-unavailable dialog, "Curhat lagi" CTA, failed-pairing terminal, extension via payment-session - mitra_app: PairingRequestType enum, returning-chat 20s countdown auto-dismiss, extension card "otomatis disetujui" copy - control_center: 4 new config rows in Settings, Failed Pairings page (filter + paginate + action menu), sidebar + route registered - Test infrastructure: Vitest backend (7/7 pass), Playwright CC (4/4 pass), Maestro mobile scaffold (CLI install pending) - Bugs found via Playwright + fixed: LoginPage labels not associated with inputs (a11y); backend internal CORS missing PATCH/PUT/DELETE in allow-methods (silent settings breakage in browsers since Stage 4) - Docs: phase3.7.md PRD, phase3.7-plan.md, phase3.7-questions.md (Q&A), phase3.7-testing.md (E2E checklist), phase3.7-test-run-2026-05-03.md (today's run results) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
180
client_app/lib/features/payment/payment_notifier.dart
Normal file
180
client_app/lib/features/payment/payment_notifier.dart
Normal file
@@ -0,0 +1,180 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/api/api_client_provider.dart';
|
||||
import '../../core/constants.dart';
|
||||
|
||||
part 'payment_notifier.g.dart';
|
||||
|
||||
/// Payment-session lifecycle, customer side. The screen owns one of these per
|
||||
/// (mitra-target, duration) attempt; the notifier wraps the REST calls to
|
||||
/// `/api/client/payment-sessions`.
|
||||
sealed class PaymentSessionData {
|
||||
const PaymentSessionData();
|
||||
}
|
||||
|
||||
class PaymentInitialData extends PaymentSessionData {
|
||||
const PaymentInitialData();
|
||||
}
|
||||
|
||||
class PaymentCreatingData extends PaymentSessionData {
|
||||
const PaymentCreatingData();
|
||||
}
|
||||
|
||||
/// Created server-side, sitting in `pending` until the customer taps "Bayar".
|
||||
class PaymentPendingData extends PaymentSessionData {
|
||||
final String paymentSessionId;
|
||||
final int amount;
|
||||
final int durationMinutes;
|
||||
final bool isFreeTrial;
|
||||
final bool isExtension;
|
||||
final String? targetedMitraId;
|
||||
|
||||
const PaymentPendingData({
|
||||
required this.paymentSessionId,
|
||||
required this.amount,
|
||||
required this.durationMinutes,
|
||||
required this.isFreeTrial,
|
||||
required this.isExtension,
|
||||
this.targetedMitraId,
|
||||
});
|
||||
}
|
||||
|
||||
class PaymentConfirmingData extends PaymentSessionData {
|
||||
final String paymentSessionId;
|
||||
const PaymentConfirmingData(this.paymentSessionId);
|
||||
}
|
||||
|
||||
/// Confirmed; the customer can now be routed to the searching screen with
|
||||
/// this `paymentSessionId` (and optional `targetedMitraId` for "Curhat lagi").
|
||||
class PaymentConfirmedData extends PaymentSessionData {
|
||||
final String paymentSessionId;
|
||||
final int durationMinutes;
|
||||
final bool isFreeTrial;
|
||||
final bool isExtension;
|
||||
final String? targetedMitraId;
|
||||
|
||||
const PaymentConfirmedData({
|
||||
required this.paymentSessionId,
|
||||
required this.durationMinutes,
|
||||
required this.isFreeTrial,
|
||||
required this.isExtension,
|
||||
this.targetedMitraId,
|
||||
});
|
||||
}
|
||||
|
||||
class PaymentErrorData extends PaymentSessionData {
|
||||
final String message;
|
||||
const PaymentErrorData(this.message);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class Payment extends _$Payment {
|
||||
ApiClient get _api => ref.read(apiClientProvider);
|
||||
|
||||
@override
|
||||
PaymentSessionData build() => const PaymentInitialData();
|
||||
|
||||
/// Create a `pending` payment session for the chosen [durationMinutes].
|
||||
/// Pass [targetedMitraId] for the "Curhat lagi" path; pass [isExtension]
|
||||
/// for an extension-cost payment (never combined with free trial).
|
||||
Future<void> createSession({
|
||||
required int durationMinutes,
|
||||
String? targetedMitraId,
|
||||
bool isExtension = false,
|
||||
}) async {
|
||||
state = const PaymentCreatingData();
|
||||
try {
|
||||
final body = <String, dynamic>{
|
||||
'duration_minutes': durationMinutes,
|
||||
if (targetedMitraId != null) 'targeted_mitra_id': targetedMitraId,
|
||||
if (isExtension) 'is_extension': true,
|
||||
};
|
||||
// Trailing slash matters: the backend route is `app.post('/', ...)` mounted
|
||||
// at prefix `/api/client/payment-sessions`, and Fastify is not configured
|
||||
// with `ignoreTrailingSlash: true`, so the canonical URL has the slash.
|
||||
final response = await _api.post('/api/client/payment-sessions/', data: body);
|
||||
final data = response['data'] as Map<String, dynamic>;
|
||||
state = PaymentPendingData(
|
||||
paymentSessionId: data['id'] as String,
|
||||
amount: data['amount'] as int? ?? 0,
|
||||
durationMinutes: data['duration_minutes'] as int? ?? durationMinutes,
|
||||
isFreeTrial: data['is_free_trial'] as bool? ?? false,
|
||||
isExtension: data['is_extension'] as bool? ?? isExtension,
|
||||
targetedMitraId: data['targeted_mitra_id'] as String?,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
state = PaymentErrorData(_humanError(e, fallback: 'Gagal membuat sesi pembayaran.'));
|
||||
} catch (_) {
|
||||
state = const PaymentErrorData('Gagal membuat sesi pembayaran.');
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm the pending payment. Backend rejects truly empty bodies on
|
||||
/// `POST .../confirm`, so we always send `{}`.
|
||||
Future<void> confirm() async {
|
||||
final current = state;
|
||||
if (current is! PaymentPendingData) return;
|
||||
state = PaymentConfirmingData(current.paymentSessionId);
|
||||
try {
|
||||
await _api.post(
|
||||
'/api/client/payment-sessions/${current.paymentSessionId}/confirm',
|
||||
data: const <String, dynamic>{},
|
||||
);
|
||||
state = PaymentConfirmedData(
|
||||
paymentSessionId: current.paymentSessionId,
|
||||
durationMinutes: current.durationMinutes,
|
||||
isFreeTrial: current.isFreeTrial,
|
||||
isExtension: current.isExtension,
|
||||
targetedMitraId: current.targetedMitraId,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
state = PaymentErrorData(_humanError(e, fallback: 'Gagal mengkonfirmasi pembayaran.'));
|
||||
} catch (_) {
|
||||
state = const PaymentErrorData('Gagal mengkonfirmasi pembayaran.');
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort cancel of a still-pending session. Safe to call on dispose
|
||||
/// even if the state isn't `pending` — we just no-op in that case.
|
||||
Future<void> cancelIfPending() async {
|
||||
final current = state;
|
||||
if (current is! PaymentPendingData) return;
|
||||
final id = current.paymentSessionId;
|
||||
try {
|
||||
await _api.post(
|
||||
'/api/client/payment-sessions/$id/cancel',
|
||||
data: const <String, dynamic>{},
|
||||
);
|
||||
} catch (_) {
|
||||
// Best-effort — backend sweeper will expire stale `pending` rows
|
||||
// after `payment_session_timeout_minutes` regardless.
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset to initial — used when the screen is re-entered for a new attempt.
|
||||
void reset() {
|
||||
state = const PaymentInitialData();
|
||||
}
|
||||
|
||||
String _humanError(DioException e, {required String fallback}) {
|
||||
final code = e.response?.data?['error']?['code'] as String?;
|
||||
final status = e.response?.statusCode;
|
||||
if (status == 422 || code == 'VALIDATION_ERROR' || code == 'INVALID_TIER') {
|
||||
return 'Pilihan durasi tidak valid.';
|
||||
}
|
||||
if (status == 403) return 'Sesi tidak diizinkan.';
|
||||
if (status == 404) return 'Sesi pembayaran tidak ditemukan.';
|
||||
if (code == 'EXPIRED') return 'Sesi pembayaran sudah kedaluwarsa.';
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror of backend `PaymentSessionStatus` for any UI that needs to inspect
|
||||
/// the raw status field (kept tiny for now — most flows route via state above).
|
||||
class PaymentStatus {
|
||||
static const pending = PaymentSessionStatus.pending;
|
||||
static const confirmed = PaymentSessionStatus.confirmed;
|
||||
static const consumed = PaymentSessionStatus.consumed;
|
||||
PaymentStatus._();
|
||||
}
|
||||
25
client_app/lib/features/payment/payment_notifier.g.dart
Normal file
25
client_app/lib/features/payment/payment_notifier.g.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'payment_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$paymentHash() => r'63019ba794311cd36761bd6ad6f90b0abde5c747';
|
||||
|
||||
/// See also [Payment].
|
||||
@ProviderFor(Payment)
|
||||
final paymentProvider =
|
||||
AutoDisposeNotifierProvider<Payment, PaymentSessionData>.internal(
|
||||
Payment.new,
|
||||
name: r'paymentProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$paymentHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$Payment = AutoDisposeNotifier<PaymentSessionData>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
390
client_app/lib/features/payment/screens/payment_screen.dart
Normal file
390
client_app/lib/features/payment/screens/payment_screen.dart
Normal file
@@ -0,0 +1,390 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/chat/chat_opening_provider.dart';
|
||||
import '../../../core/constants.dart';
|
||||
import '../../../core/pairing/pairing_notifier.dart';
|
||||
import '../payment_notifier.dart';
|
||||
|
||||
/// Payment screen.
|
||||
///
|
||||
/// Reuses the mock pricing service (tiers + free trial). The customer picks a
|
||||
/// duration (or auto-selects the free trial); on tap the screen creates a
|
||||
/// `pending` payment session, then on "Bayar" / "Mulai" confirms it and routes
|
||||
/// to the searching screen carrying `paymentSessionId` (and `targetedMitraId`
|
||||
/// if this is a "Curhat lagi" flow).
|
||||
///
|
||||
/// Reachable from:
|
||||
/// - Home "Mulai Curhat" CTA → no targeted mitra, normal blast follows.
|
||||
/// - Chat history "Curhat lagi" CTA → targetedMitraId set, returning-chat
|
||||
/// flow follows.
|
||||
class PaymentScreen extends ConsumerStatefulWidget {
|
||||
/// "Curhat lagi" only — when set, the eventual chat-request goes through
|
||||
/// the returning-chat endpoint targeting this mitra.
|
||||
final String? targetedMitraId;
|
||||
|
||||
/// Optional display name for the targeted mitra, surfaced in the screen
|
||||
/// header so the customer knows who they're paying to chat with again.
|
||||
final String? mitraName;
|
||||
|
||||
/// The topic-sensitivity choice the customer made in the topic-selection
|
||||
/// bottom sheet on the home screen. Carried through here to be passed into
|
||||
/// the chat-request API after confirm. Defaults to regular.
|
||||
final TopicSensitivity topicSensitivity;
|
||||
|
||||
const PaymentScreen({
|
||||
super.key,
|
||||
this.targetedMitraId,
|
||||
this.mitraName,
|
||||
this.topicSensitivity = TopicSensitivity.regular,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<PaymentScreen> createState() => _PaymentScreenState();
|
||||
}
|
||||
|
||||
class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
/// Local UI selection (not in the notifier) — the duration the customer is
|
||||
/// previewing before they tap to lock it in via createSession.
|
||||
int? _selectedDurationMinutes;
|
||||
|
||||
/// True once we've kicked off `createSession()` for the current selection;
|
||||
/// used to suppress double-taps while the round-trip is in flight.
|
||||
bool _creatingSession = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Make sure no stale state leaks in from a previous payment attempt.
|
||||
Future.microtask(() => ref.read(paymentProvider.notifier).reset());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Best-effort cancel on back/dispose if we still have a `pending` row.
|
||||
// The notifier checks state before calling the API, so this is safe to
|
||||
// call unconditionally.
|
||||
// ignore: discarded_futures
|
||||
ref.read(paymentProvider.notifier).cancelIfPending();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
Future<void> _onTierTapped({
|
||||
required int durationMinutes,
|
||||
required int price,
|
||||
}) async {
|
||||
if (_creatingSession) return;
|
||||
// `price` is informational (already shown in the tier card) — the source
|
||||
// of truth for the amount comes back from the backend.
|
||||
setState(() {
|
||||
_selectedDurationMinutes = durationMinutes;
|
||||
_creatingSession = true;
|
||||
});
|
||||
await ref.read(paymentProvider.notifier).createSession(
|
||||
durationMinutes: durationMinutes,
|
||||
targetedMitraId: widget.targetedMitraId,
|
||||
);
|
||||
if (mounted) setState(() => _creatingSession = false);
|
||||
}
|
||||
|
||||
Future<void> _onConfirmTapped() async {
|
||||
final notifier = ref.read(paymentProvider.notifier);
|
||||
await notifier.confirm();
|
||||
}
|
||||
|
||||
Future<void> _routeToSearchOnConfirmed(PaymentConfirmedData payment) async {
|
||||
// Kick off the right pairing flow against the freshly-confirmed payment.
|
||||
final pairing = ref.read(pairingProvider.notifier);
|
||||
if (payment.targetedMitraId != null) {
|
||||
await pairing.startTargetedSearch(
|
||||
paymentSessionId: payment.paymentSessionId,
|
||||
mitraId: payment.targetedMitraId!,
|
||||
mitraName: widget.mitraName ?? 'Bestie',
|
||||
topicSensitivity: widget.topicSensitivity,
|
||||
);
|
||||
} else {
|
||||
await pairing.startSearch(
|
||||
paymentSessionId: payment.paymentSessionId,
|
||||
topicSensitivity: widget.topicSensitivity,
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
// Reset our local notifier so a future payment attempt starts clean.
|
||||
ref.read(paymentProvider.notifier).reset();
|
||||
context.go('/chat/searching');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// One-shot side-effect listener: when the payment lands in `confirmed`,
|
||||
// route to the searching screen.
|
||||
ref.listen<PaymentSessionData>(paymentProvider, (prev, next) {
|
||||
if (next is PaymentConfirmedData) {
|
||||
// ignore: discarded_futures
|
||||
_routeToSearchOnConfirmed(next);
|
||||
}
|
||||
});
|
||||
|
||||
final paymentState = ref.watch(paymentProvider);
|
||||
final pricingAsync = ref.watch(chatPricingProvider);
|
||||
final isReturning = widget.targetedMitraId != null;
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(isReturning ? 'Chat lagi dengan ${widget.mitraName ?? 'Bestie'}' : 'Pilih Sesi & Bayar'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
onPressed: () {
|
||||
// PopScope above lets canPop fire dispose() which cancels the
|
||||
// pending session. If there's no back-stack, fall back to home.
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/home');
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
body: pricingAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text('Gagal memuat harga. Coba lagi.', textAlign: TextAlign.center),
|
||||
),
|
||||
),
|
||||
data: (pricing) => _buildBody(pricing, paymentState),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(PricingData pricing, PaymentSessionData paymentState) {
|
||||
// Inline error widget per project memory ("Avoid SnackBars for provider errors").
|
||||
final errorBanner = paymentState is PaymentErrorData
|
||||
? Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.shade200),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Colors.red.shade700),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
paymentState.message,
|
||||
style: TextStyle(color: Colors.red.shade900),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
errorBanner,
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
const Text(
|
||||
'Pilih Durasi Curhat',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (pricing.freeTrialEligible) ...[
|
||||
_FreeTrialCard(
|
||||
durationMinutes: pricing.freeTrialDurationMinutes,
|
||||
selected: paymentState is PaymentPendingData && paymentState.isFreeTrial,
|
||||
onTap: () => _onTierTapped(
|
||||
// For free trial: backend still wants a duration_minutes —
|
||||
// pass the trial duration. The backend overrides amount→0
|
||||
// when the customer is eligible.
|
||||
durationMinutes: pricing.freeTrialDurationMinutes,
|
||||
price: 0,
|
||||
),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
],
|
||||
...pricing.tiers.map((tier) {
|
||||
final selected = _selectedDurationMinutes == tier.durationMinutes &&
|
||||
paymentState is PaymentPendingData &&
|
||||
!paymentState.isFreeTrial;
|
||||
return _TierCard(
|
||||
label: tier.label,
|
||||
priceLabel: formatRupiah(tier.price),
|
||||
selected: selected,
|
||||
onTap: () => _onTierTapped(
|
||||
durationMinutes: tier.durationMinutes,
|
||||
price: tier.price,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (paymentState is PaymentPendingData ||
|
||||
paymentState is PaymentConfirmingData ||
|
||||
paymentState is PaymentCreatingData)
|
||||
_ConfirmBar(
|
||||
paymentState: paymentState,
|
||||
onConfirm: _onConfirmTapped,
|
||||
formatPrice: formatRupiah,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FreeTrialCard extends StatelessWidget {
|
||||
final int durationMinutes;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _FreeTrialCard({
|
||||
required this.durationMinutes,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
color: selected ? Colors.green.shade100 : Colors.green.shade50,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: selected
|
||||
? BorderSide(color: Colors.green.shade700, width: 1.5)
|
||||
: BorderSide.none,
|
||||
),
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.card_giftcard, color: Colors.green),
|
||||
title: Text('Free Trial ($durationMinutes Menit)'),
|
||||
subtitle: const Text('Gratis untuk pertama kali!'),
|
||||
trailing: Text(
|
||||
'Gratis',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.green.shade800),
|
||||
),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TierCard extends StatelessWidget {
|
||||
final String label;
|
||||
final String priceLabel;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _TierCard({
|
||||
required this.label,
|
||||
required this.priceLabel,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: selected
|
||||
? const BorderSide(color: Colors.pink, width: 1.5)
|
||||
: BorderSide.none,
|
||||
),
|
||||
child: ListTile(
|
||||
title: Text(label),
|
||||
trailing: Text(
|
||||
priceLabel,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConfirmBar extends StatelessWidget {
|
||||
final PaymentSessionData paymentState;
|
||||
final Future<void> Function() onConfirm;
|
||||
final String Function(int) formatPrice;
|
||||
|
||||
const _ConfirmBar({
|
||||
required this.paymentState,
|
||||
required this.onConfirm,
|
||||
required this.formatPrice,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isCreating = paymentState is PaymentCreatingData;
|
||||
final isConfirming = paymentState is PaymentConfirmingData;
|
||||
final pending = paymentState is PaymentPendingData ? paymentState as PaymentPendingData : null;
|
||||
|
||||
final totalLabel = pending == null
|
||||
? '...'
|
||||
: pending.isFreeTrial
|
||||
? 'Gratis'
|
||||
: formatPrice(pending.amount);
|
||||
final ctaLabel = pending != null && pending.isFreeTrial ? 'Mulai' : 'Bayar';
|
||||
final disabled = isCreating || isConfirming || pending == null;
|
||||
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Total', style: TextStyle(fontSize: 16)),
|
||||
Text(
|
||||
totalLabel,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
onPressed: disabled ? null : onConfirm,
|
||||
child: isConfirming || isCreating
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: Text(ctaLabel, style: const TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user