usability fixes
This commit is contained in:
4
assets/group-batch-example.csv
Normal file
4
assets/group-batch-example.csv
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
name, account, phone,billLabel, billProductName,amount
|
||||||
|
John Doe,0773591219,0773591219,ECONET_BUNDLES,Daily Data Bundle (20MB),
|
||||||
|
Jane Smith,0773591219,0773591219,ECONET,,0.1
|
||||||
|
James Top,07088597534,0773591219,ZESA,,5
|
||||||
|
@@ -9,6 +9,7 @@ import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
|||||||
import 'package:qpay/providers/splash_provider.dart';
|
import 'package:qpay/providers/splash_provider.dart';
|
||||||
import 'package:qpay/screens/workspaces/workspace_provider.dart';
|
import 'package:qpay/screens/workspaces/workspace_provider.dart';
|
||||||
import 'package:qpay/providers/uptime_provider.dart';
|
import 'package:qpay/providers/uptime_provider.dart';
|
||||||
|
import 'package:qpay/screens/accounts/account_provider.dart';
|
||||||
import 'package:qpay/theme/app_theme.dart';
|
import 'package:qpay/theme/app_theme.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
@@ -36,14 +37,13 @@ void main() async {
|
|||||||
ChangeNotifierProvider<OnboardingController>(
|
ChangeNotifierProvider<OnboardingController>(
|
||||||
create: (_) => OnboardingController(),
|
create: (_) => OnboardingController(),
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider<SplashProvider>(
|
ChangeNotifierProvider<SplashProvider>(create: (_) => SplashProvider()),
|
||||||
create: (_) => SplashProvider(),
|
|
||||||
),
|
|
||||||
ChangeNotifierProvider<WorkspaceProvider>(
|
ChangeNotifierProvider<WorkspaceProvider>(
|
||||||
create: (_) => WorkspaceProvider(),
|
create: (_) => WorkspaceProvider(),
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider<UptimeProvider>(
|
ChangeNotifierProvider<UptimeProvider>(create: (_) => UptimeProvider()),
|
||||||
create: (_) => UptimeProvider(),
|
ChangeNotifierProvider<AccountProvider>(
|
||||||
|
create: (_) => AccountProvider(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: const MyApp(),
|
child: const MyApp(),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import 'package:qpay/screens/workspaces/workspace_screen.dart';
|
|||||||
import 'package:qpay/screens/users/screens/user_list_screen.dart';
|
import 'package:qpay/screens/users/screens/user_list_screen.dart';
|
||||||
import 'package:qpay/screens/more/more_screen.dart';
|
import 'package:qpay/screens/more/more_screen.dart';
|
||||||
import 'package:qpay/screens/uptime/uptime_status_screen.dart';
|
import 'package:qpay/screens/uptime/uptime_status_screen.dart';
|
||||||
|
import 'package:qpay/screens/contact/contact_screen.dart';
|
||||||
|
|
||||||
/// Tracks whether the splash animation is complete so that
|
/// Tracks whether the splash animation is complete so that
|
||||||
/// GoRouter's redirect can show the splash first, then release
|
/// GoRouter's redirect can show the splash first, then release
|
||||||
@@ -232,6 +233,10 @@ GoRouter buildRouter() {
|
|||||||
path: '/more',
|
path: '/more',
|
||||||
builder: (context, state) => const MoreScreen(),
|
builder: (context, state) => const MoreScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/contact',
|
||||||
|
builder: (context, state) => const ContactScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/uptime',
|
path: '/uptime',
|
||||||
builder: (context, state) => const UptimeStatusScreen(),
|
builder: (context, state) => const UptimeStatusScreen(),
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
String? _accountError;
|
String? _accountError;
|
||||||
String? _statementError;
|
String? _statementError;
|
||||||
|
|
||||||
|
/// Cached balances keyed by currency code (e.g. 'USD', 'ZWG').
|
||||||
|
final Map<String, AccountModel?> _balances = {};
|
||||||
|
|
||||||
AccountModel? get account => _account;
|
AccountModel? get account => _account;
|
||||||
StatementModel? get statement => _statement;
|
StatementModel? get statement => _statement;
|
||||||
bool get isLoadingAccount => _isLoadingAccount;
|
bool get isLoadingAccount => _isLoadingAccount;
|
||||||
@@ -25,6 +28,20 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
String? get statementError => _statementError;
|
String? get statementError => _statementError;
|
||||||
bool _isDisposed = false;
|
bool _isDisposed = false;
|
||||||
|
|
||||||
|
/// Returns a cached account balance for [currency], or null if not yet
|
||||||
|
/// fetched.
|
||||||
|
AccountModel? balanceForCurrency(String currency) {
|
||||||
|
return _balances[currency.toUpperCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the account with name 'City Wallet' for [currency], or null.
|
||||||
|
AccountModel? cityWalletBalance() {
|
||||||
|
return _balances.values.firstWhere(
|
||||||
|
(a) => a?.name == 'City Wallet',
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void notifyListeners() {
|
void notifyListeners() {
|
||||||
if (!_isDisposed) {
|
if (!_isDisposed) {
|
||||||
@@ -52,6 +69,7 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
_account = AccountModel.fromJson(response);
|
_account = AccountModel.fromJson(response);
|
||||||
|
_balances[currency.toUpperCase()] = _account;
|
||||||
_isLoadingAccount = false;
|
_isLoadingAccount = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return ApiResponse.success(_account!);
|
return ApiResponse.success(_account!);
|
||||||
@@ -71,6 +89,17 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pre-load multiple currency balances at once. Results are stored in the
|
||||||
|
/// [_balances] map so any widget can access them later.
|
||||||
|
Future<void> preloadBalances(
|
||||||
|
String? workspaceId,
|
||||||
|
List<String> currencies,
|
||||||
|
) async {
|
||||||
|
for (final currency in currencies) {
|
||||||
|
await fetchAccount(workspaceId, currency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<ApiResponse<StatementModel>> fetchStatement({
|
Future<ApiResponse<StatementModel>> fetchStatement({
|
||||||
required String workspaceId,
|
required String workspaceId,
|
||||||
required String currency,
|
required String currency,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'package:qpay/screens/accounts/account_provider.dart';
|
import 'package:qpay/screens/accounts/account_provider.dart';
|
||||||
import 'package:qpay/screens/accounts/models/account_model.dart';
|
import 'package:qpay/screens/accounts/models/account_model.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
@@ -20,8 +21,6 @@ class AccountBalanceWidget extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
|
class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
|
||||||
late AccountProvider _accountProvider;
|
|
||||||
|
|
||||||
bool _isLoggedIn = false;
|
bool _isLoggedIn = false;
|
||||||
String? _workspaceId;
|
String? _workspaceId;
|
||||||
|
|
||||||
@@ -36,20 +35,12 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_accountProvider = AccountProvider();
|
|
||||||
_loadAuthStateAndBalances();
|
_loadAuthStateAndBalances();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_accountProvider.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadAuthStateAndBalances() async {
|
Future<void> _loadAuthStateAndBalances() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
// Determine login state from persisted token
|
|
||||||
if (prefs.getString("token") != null) {
|
if (prefs.getString("token") != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoggedIn = true;
|
_isLoggedIn = true;
|
||||||
@@ -62,18 +53,37 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!_isLoggedIn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final accountProvider = context.read<AccountProvider>();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoadingUsd = true;
|
_isLoadingUsd = true;
|
||||||
_isLoadingZwG = true;
|
_isLoadingZwG = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// no need to proceed if not logged in
|
// Try the cached balances first
|
||||||
if (!_isLoggedIn) {
|
final cachedUsd = accountProvider.balanceForCurrency('USD');
|
||||||
|
final cachedZwg = accountProvider.balanceForCurrency('ZWG');
|
||||||
|
|
||||||
|
if (cachedUsd != null && cachedZwg != null) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_usdAccount = cachedUsd;
|
||||||
|
_zwgAccount = cachedZwg;
|
||||||
|
_isLoadingUsd = false;
|
||||||
|
_isLoadingZwG = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch USD balance
|
// Fetch USD balance
|
||||||
final usdResult = await _accountProvider.fetchAccount(_workspaceId, 'USD');
|
final usdResult = await accountProvider.fetchAccount(_workspaceId, 'USD');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoadingUsd = false;
|
_isLoadingUsd = false;
|
||||||
@@ -87,7 +97,7 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch ZWG balance
|
// Fetch ZWG balance
|
||||||
final zwgResult = await _accountProvider.fetchAccount(_workspaceId, 'ZWG');
|
final zwgResult = await accountProvider.fetchAccount(_workspaceId, 'ZWG');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoadingZwG = false;
|
_isLoadingZwG = false;
|
||||||
@@ -117,12 +127,16 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
|
|||||||
|
|
||||||
if (_workspaceId == null || _workspaceId!.isEmpty) return;
|
if (_workspaceId == null || _workspaceId!.isEmpty) return;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final accountProvider = context.read<AccountProvider>();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoadingUsd = true;
|
_isLoadingUsd = true;
|
||||||
_isLoadingZwG = true;
|
_isLoadingZwG = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
final usdResult = await _accountProvider.fetchAccount(_workspaceId, 'USD');
|
final usdResult = await accountProvider.fetchAccount(_workspaceId, 'USD');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoadingUsd = false;
|
_isLoadingUsd = false;
|
||||||
@@ -135,7 +149,7 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
final zwgResult = await _accountProvider.fetchAccount(_workspaceId, 'ZWG');
|
final zwgResult = await accountProvider.fetchAccount(_workspaceId, 'ZWG');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoadingZwG = false;
|
_isLoadingZwG = false;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
|
import 'package:qpay/screens/accounts/account_provider.dart';
|
||||||
import 'package:qpay/screens/confirm/confirm_controller.dart';
|
import 'package:qpay/screens/confirm/confirm_controller.dart';
|
||||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
@@ -600,7 +602,12 @@ class _ConfirmScreenState extends State<ConfirmScreen>
|
|||||||
onTap: () async {
|
onTap: () async {
|
||||||
_handlePayment();
|
_handlePayment();
|
||||||
},
|
},
|
||||||
child: ListTile(
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: Container(
|
leading: Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -629,7 +636,10 @@ class _ConfirmScreenState extends State<ConfirmScreen>
|
|||||||
.model
|
.model
|
||||||
.selectedPaymentProcessor
|
.selectedPaymentProcessor
|
||||||
.description,
|
.description,
|
||||||
style: TextStyle(fontWeight: FontWeight.normal, fontSize: 10),
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.normal,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
trailing: Icon(
|
trailing: Icon(
|
||||||
Icons.chevron_right,
|
Icons.chevron_right,
|
||||||
@@ -638,6 +648,68 @@ class _ConfirmScreenState extends State<ConfirmScreen>
|
|||||||
).colorScheme.secondary.withValues(alpha: 0.7),
|
).colorScheme.secondary.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final accountProvider = context.watch<AccountProvider>();
|
||||||
|
final currency = controller
|
||||||
|
.transactionController
|
||||||
|
.model
|
||||||
|
.confirmationData["debitCurrency"]
|
||||||
|
?.toString();
|
||||||
|
final cityWallet = currency != null
|
||||||
|
? accountProvider.balanceForCurrency(currency)
|
||||||
|
: null;
|
||||||
|
final hasCityWallet = cityWallet != null;
|
||||||
|
if (!hasCityWallet) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
if (controller
|
||||||
|
.transactionController
|
||||||
|
.model
|
||||||
|
.selectedPaymentProcessor
|
||||||
|
.label !=
|
||||||
|
'WALLET') {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4, bottom: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.account_balance_wallet_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'City Wallet Balance',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'\$${cityWallet!.balance.toStringAsFixed(2)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
296
lib/screens/contact/contact_screen.dart
Normal file
296
lib/screens/contact/contact_screen.dart
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
|
|
||||||
|
class ContactScreen extends StatelessWidget {
|
||||||
|
const ContactScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: isDark
|
||||||
|
? const Color(0xFF0D0D0D)
|
||||||
|
: const Color(0xFFF8F9FA),
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: context.canPop()
|
||||||
|
? IconButton(
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
title: Text(
|
||||||
|
'Contact Us',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 20,
|
||||||
|
color: isDark ? Colors.white : Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
backgroundColor: isDark
|
||||||
|
? const Color(0xFF0D0D0D)
|
||||||
|
: const Color(0xFFF8F9FA),
|
||||||
|
),
|
||||||
|
body: Center(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
|
||||||
|
? ResponsivePolicy.md.toDouble()
|
||||||
|
: constraints.maxWidth;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: maxWidth,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
// Header card
|
||||||
|
_buildHeaderCard(theme, isDark),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Social links card
|
||||||
|
_buildSocialLinksCard(theme, isDark),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeaderCard(ThemeData theme, bool isDark) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark
|
||||||
|
? Colors.white.withValues(alpha: 0.06)
|
||||||
|
: Colors.grey.shade200,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: isDark
|
||||||
|
? Colors.black.withValues(alpha: 0.3)
|
||||||
|
: Colors.black.withValues(alpha: 0.04),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
theme.colorScheme.primary.withValues(alpha: 0.2),
|
||||||
|
theme.colorScheme.primary.withValues(alpha: 0.05),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.primary.withValues(alpha: 0.15),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.support_agent_rounded,
|
||||||
|
size: 36,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'We\'d love to hear from you',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isDark ? Colors.white : Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Reach out to us through any of the channels below.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: isDark ? Colors.white60 : Colors.grey.shade600,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSocialLinksCard(ThemeData theme, bool isDark) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark
|
||||||
|
? Colors.white.withValues(alpha: 0.06)
|
||||||
|
: Colors.grey.shade200,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: isDark
|
||||||
|
? Colors.black.withValues(alpha: 0.3)
|
||||||
|
: Colors.black.withValues(alpha: 0.04),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
'Connect With Us',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isDark ? Colors.white54 : Colors.grey.shade500,
|
||||||
|
letterSpacing: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
|
// Instagram
|
||||||
|
_buildSocialTile(
|
||||||
|
theme: theme,
|
||||||
|
isDark: isDark,
|
||||||
|
icon: Icons.camera_alt_rounded,
|
||||||
|
iconBackgroundColor: const Color(0xFFE1306C),
|
||||||
|
title: 'Instagram',
|
||||||
|
subtitle: 'Follow us for updates',
|
||||||
|
onTap: () {
|
||||||
|
// Instagram link will be added later
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// WhatsApp
|
||||||
|
_buildSocialTile(
|
||||||
|
theme: theme,
|
||||||
|
isDark: isDark,
|
||||||
|
icon: Icons.chat_rounded,
|
||||||
|
iconBackgroundColor: const Color(0xFF25D366),
|
||||||
|
title: 'WhatsApp',
|
||||||
|
subtitle: 'Chat with us directly',
|
||||||
|
onTap: () {
|
||||||
|
// WhatsApp link will be added later
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSocialTile({
|
||||||
|
required ThemeData theme,
|
||||||
|
required bool isDark,
|
||||||
|
required IconData icon,
|
||||||
|
required Color iconBackgroundColor,
|
||||||
|
required String title,
|
||||||
|
required String subtitle,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark
|
||||||
|
? Colors.white.withValues(alpha: 0.03)
|
||||||
|
: Colors.grey.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark
|
||||||
|
? Colors.white.withValues(alpha: 0.04)
|
||||||
|
: Colors.grey.shade100,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: iconBackgroundColor.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Icon(icon, color: iconBackgroundColor, size: 24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isDark ? Colors.white : Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: isDark ? Colors.white54 : Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: iconBackgroundColor.withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.arrow_forward_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,9 +105,11 @@ class BatchController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ApiResponse<List<PaymentProcessor>>> loadProcessors() async {
|
Future<ApiResponse<List<PaymentProcessor>>> loadProcessors(currency) async {
|
||||||
try {
|
try {
|
||||||
final raw = await http.get('/public/payment-processors');
|
final raw = await http.get(
|
||||||
|
'/public/payment-processors?currency=$currency',
|
||||||
|
);
|
||||||
final response = _unwrap(raw);
|
final response = _unwrap(raw);
|
||||||
final List<dynamic> content = response is List
|
final List<dynamic> content = response is List
|
||||||
? response
|
? response
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class GroupBatchItem {
|
|||||||
final String? billProductName;
|
final String? billProductName;
|
||||||
final String? providerLabel;
|
final String? providerLabel;
|
||||||
final String? providerImage;
|
final String? providerImage;
|
||||||
|
final String? creditAddress;
|
||||||
|
|
||||||
const GroupBatchItem({
|
const GroupBatchItem({
|
||||||
this.id,
|
this.id,
|
||||||
@@ -78,6 +79,7 @@ class GroupBatchItem {
|
|||||||
this.billProductName,
|
this.billProductName,
|
||||||
this.providerLabel,
|
this.providerLabel,
|
||||||
this.providerImage,
|
this.providerImage,
|
||||||
|
this.creditAddress,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory GroupBatchItem.fromJson(Map<String, dynamic> json) =>
|
factory GroupBatchItem.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
|||||||
}
|
}
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
providerController.loadProviders(_currency),
|
providerController.loadProviders(_currency),
|
||||||
batchController.loadProcessors(),
|
batchController.loadProcessors(_currency),
|
||||||
]);
|
]);
|
||||||
if (mounted) setState(() => _loadingDropdowns = false);
|
if (mounted) setState(() => _loadingDropdowns = false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:qpay/screens/accounts/account_provider.dart';
|
||||||
import 'package:qpay/screens/confirm/confirm_controller.dart';
|
import 'package:qpay/screens/confirm/confirm_controller.dart';
|
||||||
import 'package:qpay/screens/groups/models/batch_item_update_model.dart';
|
import 'package:qpay/screens/groups/models/batch_item_update_model.dart';
|
||||||
import 'package:qpay/screens/groups/models/group_batch_model.dart';
|
import 'package:qpay/screens/groups/models/group_batch_model.dart';
|
||||||
@@ -16,10 +18,7 @@ import 'package:url_launcher/url_launcher.dart';
|
|||||||
class BatchDetailScreen extends StatefulWidget {
|
class BatchDetailScreen extends StatefulWidget {
|
||||||
final String batchId;
|
final String batchId;
|
||||||
|
|
||||||
const BatchDetailScreen({
|
const BatchDetailScreen({super.key, required this.batchId});
|
||||||
super.key,
|
|
||||||
required this.batchId,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BatchDetailScreen> createState() => _BatchDetailScreenState();
|
State<BatchDetailScreen> createState() => _BatchDetailScreenState();
|
||||||
@@ -59,7 +58,6 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
controller = BatchController(context);
|
controller = BatchController(context);
|
||||||
controller.loadProcessors();
|
|
||||||
|
|
||||||
_entryController = AnimationController(
|
_entryController = AnimationController(
|
||||||
duration: const Duration(milliseconds: 220),
|
duration: const Duration(milliseconds: 220),
|
||||||
@@ -96,6 +94,8 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_initialLoading = false;
|
_initialLoading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
controller.loadProcessors(_batch!.currency);
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
setState(() {
|
||||||
_initialLoading = false;
|
_initialLoading = false;
|
||||||
@@ -337,10 +337,14 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
width: isSelected ? 2 : 1,
|
width: isSelected ? 2 : 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(
|
||||||
|
8,
|
||||||
|
),
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/${processor.image}',
|
'assets/${processor.image}',
|
||||||
width: 36,
|
width: 36,
|
||||||
@@ -389,6 +393,66 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
// Row 2.5: City Wallet balance (if available for batch currency)
|
||||||
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final accountProvider = context
|
||||||
|
.watch<AccountProvider>();
|
||||||
|
final batchCurrency =
|
||||||
|
_batch!.currency ?? 'USD';
|
||||||
|
final cityWallet = accountProvider
|
||||||
|
.balanceForCurrency(batchCurrency);
|
||||||
|
final hasCityWallet =
|
||||||
|
cityWallet != null;
|
||||||
|
if (!hasCityWallet)
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
if (processor.label != 'WALLET')
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 8,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons
|
||||||
|
.account_balance_wallet_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
|
.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'City Wallet Balance',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
|
.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'\$${cityWallet!.balance.toStringAsFixed(2)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -2458,6 +2522,7 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
if (item.billName != null || item.billProductName != null)
|
if (item.billName != null || item.billProductName != null)
|
||||||
_buildBillInfoRow(item, isDark),
|
_buildBillInfoRow(item, isDark),
|
||||||
|
if (item.creditAddress != null) Text(item.creditAddress ?? ''),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
import 'dart:io' show File, Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:qpay/screens/groups/controllers/group_controller.dart';
|
import 'package:qpay/screens/groups/controllers/group_controller.dart';
|
||||||
import 'package:qpay/screens/groups/models/create_group_from_file_response.dart';
|
import 'package:qpay/screens/groups/models/create_group_from_file_response.dart';
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class CreateGroupFromFileScreen extends StatefulWidget {
|
class CreateGroupFromFileScreen extends StatefulWidget {
|
||||||
const CreateGroupFromFileScreen({super.key});
|
const CreateGroupFromFileScreen({super.key});
|
||||||
@@ -38,6 +44,40 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _downloadTemplate() async {
|
||||||
|
try {
|
||||||
|
final bytes = await rootBundle.load('assets/group-batch-example.csv');
|
||||||
|
final data = bytes.buffer.asUint8List();
|
||||||
|
|
||||||
|
if (kIsWeb) {
|
||||||
|
// Web: use url_launcher with a data URI
|
||||||
|
final uri = Uri.dataFromString(
|
||||||
|
String.fromCharCodes(data),
|
||||||
|
mimeType: 'text/csv',
|
||||||
|
encoding: null,
|
||||||
|
);
|
||||||
|
await launchUrl(uri, mode: LaunchMode.platformDefault);
|
||||||
|
} else {
|
||||||
|
// Mobile/desktop: write to temp file and open with url_launcher
|
||||||
|
final dir = Platform.isAndroid
|
||||||
|
? await getTemporaryDirectory()
|
||||||
|
: await getApplicationDocumentsDirectory();
|
||||||
|
final file = File('${dir.path}/group-batch-example.csv');
|
||||||
|
await file.writeAsBytes(data);
|
||||||
|
final fileUri = Uri.file(file.path);
|
||||||
|
if (await canLaunchUrl(fileUri)) {
|
||||||
|
await launchUrl(fileUri, mode: LaunchMode.externalApplication);
|
||||||
|
} else {
|
||||||
|
throw Exception('Cannot open file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
AppSnackBar.showError(context, 'Failed to download template: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _pickFile() async {
|
Future<void> _pickFile() async {
|
||||||
final result = await FilePicker.pickFiles(
|
final result = await FilePicker.pickFiles(
|
||||||
type: FileType.custom,
|
type: FileType.custom,
|
||||||
@@ -95,9 +135,7 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (groupId.isNotEmpty && batch.id != null) {
|
if (groupId.isNotEmpty && batch.id != null) {
|
||||||
context.pushReplacement(
|
context.pushReplacement('/groups/batches/${batch.id}');
|
||||||
'/groups/$groupId/batches/${batch.id}',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Partial failure — show errors
|
// Partial failure — show errors
|
||||||
@@ -122,9 +160,7 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
|||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
||||||
borderRadius: const BorderRadius.vertical(
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
top: Radius.circular(24),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -144,8 +180,11 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
|||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.warning_amber_rounded,
|
Icon(
|
||||||
color: Colors.orange.shade700, size: 24),
|
Icons.warning_amber_rounded,
|
||||||
|
color: Colors.orange.shade700,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Text(
|
Text(
|
||||||
'Upload completed with errors',
|
'Upload completed with errors',
|
||||||
@@ -161,18 +200,14 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
|||||||
Text(
|
Text(
|
||||||
'${response.errorCount} of ${response.totalRows} rows failed. '
|
'${response.errorCount} of ${response.totalRows} rows failed. '
|
||||||
'${response.successCount} rows succeeded.',
|
'${response.successCount} rows succeeded.',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||||||
fontSize: 13,
|
|
||||||
color: Colors.grey.shade600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (response.errors.isNotEmpty) ...[
|
if (response.errors.isNotEmpty) ...[
|
||||||
Flexible(
|
Flexible(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxHeight:
|
maxHeight: MediaQuery.of(sheetContext).size.height * 0.4,
|
||||||
MediaQuery.of(sheetContext).size.height * 0.4,
|
|
||||||
),
|
),
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
@@ -241,10 +276,7 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: const Text('Create from File'), centerTitle: true),
|
||||||
title: const Text('Create from File'),
|
|
||||||
centerTitle: true,
|
|
||||||
),
|
|
||||||
body: ListenableBuilder(
|
body: ListenableBuilder(
|
||||||
listenable: controller,
|
listenable: controller,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
@@ -272,8 +304,7 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
validator: (v) =>
|
validator: (v) => (v == null || v.trim().isEmpty)
|
||||||
(v == null || v.trim().isEmpty)
|
|
||||||
? 'Group name is required'
|
? 'Group name is required'
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -344,10 +375,9 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
|||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: _fileBytes != null
|
color: _fileBytes != null
|
||||||
? Theme.of(context)
|
? Theme.of(
|
||||||
.colorScheme
|
context,
|
||||||
.primary
|
).colorScheme.primary.withAlpha(12)
|
||||||
.withAlpha(12)
|
|
||||||
: Colors.grey.shade50,
|
: Colors.grey.shade50,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -363,14 +393,15 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
_selectedFileName ?? 'Tap to select CSV file',
|
_selectedFileName ??
|
||||||
|
'Tap to select CSV file',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: _fileBytes != null
|
color: _fileBytes != null
|
||||||
? Theme.of(context)
|
? Theme.of(
|
||||||
.colorScheme
|
context,
|
||||||
.primary
|
).colorScheme.primary
|
||||||
: Colors.grey.shade600,
|
: Colors.grey.shade600,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@@ -389,14 +420,32 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: _downloadTemplate,
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.download_rounded,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
label: const Text('Download Template'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: controller.model.isLoading
|
onPressed: controller.model.isLoading
|
||||||
? null
|
? null
|
||||||
: _submit,
|
: _submit,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
padding:
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
const EdgeInsets.symmetric(vertical: 16),
|
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:qpay/config/app_config.dart';
|
||||||
import 'package:qpay/http/http.dart';
|
import 'package:qpay/http/http.dart';
|
||||||
import 'package:qpay/models/api_response.dart';
|
import 'package:qpay/models/api_response.dart';
|
||||||
import 'package:qpay/models/pageable_model.dart';
|
import 'package:qpay/models/pageable_model.dart';
|
||||||
@@ -183,6 +186,58 @@ class HistoryController extends ChangeNotifier {
|
|||||||
_safeNotify();
|
_safeNotify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the fully-qualified download URL for the transactions report.
|
||||||
|
Uri _buildReportUrl({
|
||||||
|
required String workspaceId,
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
}) {
|
||||||
|
final df = DateFormat("yyyy-MM-dd'T'HH:mm:ss");
|
||||||
|
final start = Uri.encodeComponent(
|
||||||
|
df.format(
|
||||||
|
DateTime(startDate.year, startDate.month, startDate.day, 0, 0, 0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final end = Uri.encodeComponent(
|
||||||
|
df.format(
|
||||||
|
DateTime(endDate.year, endDate.month, endDate.day, 23, 59, 59),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Uri.parse(
|
||||||
|
'${AppConfig.baseUrl}/public/reports/transactions/download'
|
||||||
|
'?startDate=$start'
|
||||||
|
'&endDate=$end'
|
||||||
|
'&workspaceId=$workspaceId',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the transactions report download URL in the device browser.
|
||||||
|
/// The browser handles the actual file download natively — no file I/O
|
||||||
|
/// or plugin channels required, works on Android, iOS, and web.
|
||||||
|
Future<void> downloadReport({
|
||||||
|
required String workspaceId,
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
}) async {
|
||||||
|
model.isActing = true;
|
||||||
|
_safeNotify();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final uri = _buildReportUrl(
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
);
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
} catch (e) {
|
||||||
|
logger.e('Failed to open report URL: $e');
|
||||||
|
model.errorMessage = 'Failed to download report';
|
||||||
|
} finally {
|
||||||
|
model.isActing = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void clearFilters() {
|
void clearFilters() {
|
||||||
model.statusFilter = null;
|
model.statusFilter = null;
|
||||||
model.typeFilter = null;
|
model.typeFilter = null;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:qpay/auth_state.dart';
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
import 'package:qpay/screens/receipt/receipt_controller.dart';
|
import 'package:qpay/screens/receipt/receipt_controller.dart';
|
||||||
import 'package:qpay/screens/transaction_controller.dart';
|
import 'package:qpay/screens/transaction_controller.dart';
|
||||||
@@ -251,6 +253,142 @@ class _HistoryScreenState extends State<HistoryScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shows the report download bottom sheet with start/end date pickers.
|
||||||
|
void _showReportSheet() {
|
||||||
|
DateTime selectedStart = DateTime.now().subtract(const Duration(days: 7));
|
||||||
|
DateTime selectedEnd = DateTime.now();
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (ctx) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setSheetState) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
top: 20,
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Download Report',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildDateField(
|
||||||
|
label: 'Start Date',
|
||||||
|
date: selectedStart,
|
||||||
|
onTap: () async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: selectedStart,
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: selectedEnd,
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setSheetState(() => selectedStart = picked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildDateField(
|
||||||
|
label: 'End Date',
|
||||||
|
date: selectedEnd,
|
||||||
|
onTap: () async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: selectedEnd,
|
||||||
|
firstDate: selectedStart,
|
||||||
|
lastDate: DateTime.now(),
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setSheetState(() => selectedEnd = picked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: controller.model.isActing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.download),
|
||||||
|
label: Text(
|
||||||
|
controller.model.isActing
|
||||||
|
? 'Downloading…'
|
||||||
|
: 'Download Report',
|
||||||
|
),
|
||||||
|
onPressed: controller.model.isActing
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
final workspaceId =
|
||||||
|
prefs.getString("workspaceId") ?? '';
|
||||||
|
controller.downloadReport(
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
startDate: selectedStart,
|
||||||
|
endDate: selectedEnd,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a tappable date field for the report sheet.
|
||||||
|
Widget _buildDateField({
|
||||||
|
required String label,
|
||||||
|
required DateTime date,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
final df = DateFormat('dd MMM yyyy');
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'$label: ${df.format(date)}',
|
||||||
|
style: const TextStyle(fontSize: 15),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(Icons.calendar_today, size: 20, color: Colors.grey.shade600),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
@@ -396,6 +534,15 @@ class _HistoryScreenState extends State<HistoryScreen>
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
// Report download — only visible to logged-in users.
|
||||||
|
if (authState.isLoggedIn)
|
||||||
|
_buildIconButton(
|
||||||
|
icon: Icons.description_outlined,
|
||||||
|
tooltip: 'Download report',
|
||||||
|
isActive: false,
|
||||||
|
onPressed: _showReportSheet,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
_buildIconButton(
|
_buildIconButton(
|
||||||
icon: Icons.add,
|
icon: Icons.add,
|
||||||
tooltip: 'New payment',
|
tooltip: 'New payment',
|
||||||
|
|||||||
@@ -1,11 +1,61 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../../auth_state.dart';
|
||||||
import '../../models/responsive_policy.dart';
|
import '../../models/responsive_policy.dart';
|
||||||
|
|
||||||
class MoreScreen extends StatelessWidget {
|
class MoreScreen extends StatefulWidget {
|
||||||
const MoreScreen({super.key});
|
const MoreScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MoreScreen> createState() => _MoreScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MoreScreenState extends State<MoreScreen> {
|
||||||
|
late SharedPreferences _prefs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initPrefs();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initPrefs() async {
|
||||||
|
_prefs = await SharedPreferences.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showLogoutDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext dialogContext) {
|
||||||
|
return AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
title: const Text('Confirm Logout'),
|
||||||
|
content: const Text('Are you sure you want to log out?'),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
child: const Text('Logout'),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
_prefs.clear();
|
||||||
|
authState.refresh();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -22,6 +72,12 @@ class MoreScreen extends StatelessWidget {
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
|
_NavOptionTile(
|
||||||
|
icon: Icons.swap_horiz,
|
||||||
|
title: 'Change Workspace',
|
||||||
|
subtitle: 'Switch to a different workspace',
|
||||||
|
onTap: () => context.go('/workspace'),
|
||||||
|
),
|
||||||
_NavOptionTile(
|
_NavOptionTile(
|
||||||
icon: Icons.people_outline,
|
icon: Icons.people_outline,
|
||||||
title: 'Users',
|
title: 'Users',
|
||||||
@@ -34,6 +90,18 @@ class MoreScreen extends StatelessWidget {
|
|||||||
subtitle: 'View system uptime and service status',
|
subtitle: 'View system uptime and service status',
|
||||||
onTap: () => context.push('/uptime'),
|
onTap: () => context.push('/uptime'),
|
||||||
),
|
),
|
||||||
|
_NavOptionTile(
|
||||||
|
icon: Icons.support_agent_rounded,
|
||||||
|
title: 'Contact Us',
|
||||||
|
subtitle: 'Get in touch with our team',
|
||||||
|
onTap: () => context.push('/contact'),
|
||||||
|
),
|
||||||
|
_NavOptionTile(
|
||||||
|
icon: Icons.logout,
|
||||||
|
title: 'Logout',
|
||||||
|
subtitle: 'Sign out of your account',
|
||||||
|
onTap: () => _showLogoutDialog(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ abstract class PaymentProcessor with _$PaymentProcessor {
|
|||||||
required String accountFieldName,
|
required String accountFieldName,
|
||||||
required String accountFieldLabel,
|
required String accountFieldLabel,
|
||||||
required String label,
|
required String label,
|
||||||
|
required String currency,
|
||||||
required String authType,
|
required String authType,
|
||||||
}) = _PaymentProcessor;
|
}) = _PaymentProcessor;
|
||||||
|
|
||||||
@@ -176,15 +177,22 @@ class PayController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ApiResponse<List<PaymentProcessor>>> getPaymentProcessors() async {
|
Future<ApiResponse<List<PaymentProcessor>>> getPaymentProcessors(
|
||||||
|
String currency,
|
||||||
|
) async {
|
||||||
try {
|
try {
|
||||||
model.isLoading = true;
|
model.isLoading = true;
|
||||||
model.paymentProcessors = getFakePaymentProcessors();
|
model.paymentProcessors = getFakePaymentProcessors();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
List<dynamic> response = await http.get('/public/payment-processors');
|
final response = await http.get(
|
||||||
model.paymentProcessors = response
|
'/public/payment-processors?currency=$currency',
|
||||||
.map((e) => PaymentProcessor.fromJson(e))
|
);
|
||||||
|
final List<dynamic> content = response is List
|
||||||
|
? response
|
||||||
|
: (response['content'] ?? []);
|
||||||
|
model.paymentProcessors = content
|
||||||
|
.map((e) => PaymentProcessor.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
model.isLoading = false;
|
model.isLoading = false;
|
||||||
@@ -238,6 +246,7 @@ class PayController extends ChangeNotifier {
|
|||||||
accountFieldLabel: "EcoCash Phone Number",
|
accountFieldLabel: "EcoCash Phone Number",
|
||||||
label: "ECOCASH",
|
label: "ECOCASH",
|
||||||
authType: "REMOTE",
|
authType: "REMOTE",
|
||||||
|
currency: "USD",
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
|
|||||||
/// @nodoc
|
/// @nodoc
|
||||||
mixin _$PaymentProcessor implements DiagnosticableTreeMixin {
|
mixin _$PaymentProcessor implements DiagnosticableTreeMixin {
|
||||||
|
|
||||||
String get name; String get description; String get image; String get accountFieldName; String get accountFieldLabel; String get label; String get authType;
|
String get name; String get description; String get image; String get accountFieldName; String get accountFieldLabel; String get label; String get currency; String get authType;
|
||||||
/// Create a copy of PaymentProcessor
|
/// Create a copy of PaymentProcessor
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@@ -29,21 +29,21 @@ $PaymentProcessorCopyWith<PaymentProcessor> get copyWith => _$PaymentProcessorCo
|
|||||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||||
properties
|
properties
|
||||||
..add(DiagnosticsProperty('type', 'PaymentProcessor'))
|
..add(DiagnosticsProperty('type', 'PaymentProcessor'))
|
||||||
..add(DiagnosticsProperty('name', name))..add(DiagnosticsProperty('description', description))..add(DiagnosticsProperty('image', image))..add(DiagnosticsProperty('accountFieldName', accountFieldName))..add(DiagnosticsProperty('accountFieldLabel', accountFieldLabel))..add(DiagnosticsProperty('label', label))..add(DiagnosticsProperty('authType', authType));
|
..add(DiagnosticsProperty('name', name))..add(DiagnosticsProperty('description', description))..add(DiagnosticsProperty('image', image))..add(DiagnosticsProperty('accountFieldName', accountFieldName))..add(DiagnosticsProperty('accountFieldLabel', accountFieldLabel))..add(DiagnosticsProperty('label', label))..add(DiagnosticsProperty('currency', currency))..add(DiagnosticsProperty('authType', authType));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is PaymentProcessor&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.image, image) || other.image == image)&&(identical(other.accountFieldName, accountFieldName) || other.accountFieldName == accountFieldName)&&(identical(other.accountFieldLabel, accountFieldLabel) || other.accountFieldLabel == accountFieldLabel)&&(identical(other.label, label) || other.label == label)&&(identical(other.authType, authType) || other.authType == authType));
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is PaymentProcessor&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.image, image) || other.image == image)&&(identical(other.accountFieldName, accountFieldName) || other.accountFieldName == accountFieldName)&&(identical(other.accountFieldLabel, accountFieldLabel) || other.accountFieldLabel == accountFieldLabel)&&(identical(other.label, label) || other.label == label)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.authType, authType) || other.authType == authType));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(runtimeType,name,description,image,accountFieldName,accountFieldLabel,label,authType);
|
int get hashCode => Object.hash(runtimeType,name,description,image,accountFieldName,accountFieldLabel,label,currency,authType);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
|
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
|
||||||
return 'PaymentProcessor(name: $name, description: $description, image: $image, accountFieldName: $accountFieldName, accountFieldLabel: $accountFieldLabel, label: $label, authType: $authType)';
|
return 'PaymentProcessor(name: $name, description: $description, image: $image, accountFieldName: $accountFieldName, accountFieldLabel: $accountFieldLabel, label: $label, currency: $currency, authType: $authType)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ abstract mixin class $PaymentProcessorCopyWith<$Res> {
|
|||||||
factory $PaymentProcessorCopyWith(PaymentProcessor value, $Res Function(PaymentProcessor) _then) = _$PaymentProcessorCopyWithImpl;
|
factory $PaymentProcessorCopyWith(PaymentProcessor value, $Res Function(PaymentProcessor) _then) = _$PaymentProcessorCopyWithImpl;
|
||||||
@useResult
|
@useResult
|
||||||
$Res call({
|
$Res call({
|
||||||
String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String authType
|
String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String currency, String authType
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ class _$PaymentProcessorCopyWithImpl<$Res>
|
|||||||
|
|
||||||
/// Create a copy of PaymentProcessor
|
/// Create a copy of PaymentProcessor
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? description = null,Object? image = null,Object? accountFieldName = null,Object? accountFieldLabel = null,Object? label = null,Object? authType = null,}) {
|
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? description = null,Object? image = null,Object? accountFieldName = null,Object? accountFieldLabel = null,Object? label = null,Object? currency = null,Object? authType = null,}) {
|
||||||
return _then(_self.copyWith(
|
return _then(_self.copyWith(
|
||||||
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
||||||
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||||
@@ -79,6 +79,7 @@ as String,image: null == image ? _self.image : image // ignore: cast_nullable_to
|
|||||||
as String,accountFieldName: null == accountFieldName ? _self.accountFieldName : accountFieldName // ignore: cast_nullable_to_non_nullable
|
as String,accountFieldName: null == accountFieldName ? _self.accountFieldName : accountFieldName // ignore: cast_nullable_to_non_nullable
|
||||||
as String,accountFieldLabel: null == accountFieldLabel ? _self.accountFieldLabel : accountFieldLabel // ignore: cast_nullable_to_non_nullable
|
as String,accountFieldLabel: null == accountFieldLabel ? _self.accountFieldLabel : accountFieldLabel // ignore: cast_nullable_to_non_nullable
|
||||||
as String,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
|
as String,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,currency: null == currency ? _self.currency : currency // ignore: cast_nullable_to_non_nullable
|
||||||
as String,authType: null == authType ? _self.authType : authType // ignore: cast_nullable_to_non_nullable
|
as String,authType: null == authType ? _self.authType : authType // ignore: cast_nullable_to_non_nullable
|
||||||
as String,
|
as String,
|
||||||
));
|
));
|
||||||
@@ -165,10 +166,10 @@ return $default(_that);case _:
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String authType)? $default,{required TResult orElse(),}) {final _that = this;
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String currency, String authType)? $default,{required TResult orElse(),}) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case _PaymentProcessor() when $default != null:
|
case _PaymentProcessor() when $default != null:
|
||||||
return $default(_that.name,_that.description,_that.image,_that.accountFieldName,_that.accountFieldLabel,_that.label,_that.authType);case _:
|
return $default(_that.name,_that.description,_that.image,_that.accountFieldName,_that.accountFieldLabel,_that.label,_that.currency,_that.authType);case _:
|
||||||
return orElse();
|
return orElse();
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -186,10 +187,10 @@ return $default(_that.name,_that.description,_that.image,_that.accountFieldName,
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String authType) $default,) {final _that = this;
|
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String currency, String authType) $default,) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case _PaymentProcessor():
|
case _PaymentProcessor():
|
||||||
return $default(_that.name,_that.description,_that.image,_that.accountFieldName,_that.accountFieldLabel,_that.label,_that.authType);case _:
|
return $default(_that.name,_that.description,_that.image,_that.accountFieldName,_that.accountFieldLabel,_that.label,_that.currency,_that.authType);case _:
|
||||||
throw StateError('Unexpected subclass');
|
throw StateError('Unexpected subclass');
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -206,10 +207,10 @@ return $default(_that.name,_that.description,_that.image,_that.accountFieldName,
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String authType)? $default,) {final _that = this;
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String currency, String authType)? $default,) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case _PaymentProcessor() when $default != null:
|
case _PaymentProcessor() when $default != null:
|
||||||
return $default(_that.name,_that.description,_that.image,_that.accountFieldName,_that.accountFieldLabel,_that.label,_that.authType);case _:
|
return $default(_that.name,_that.description,_that.image,_that.accountFieldName,_that.accountFieldLabel,_that.label,_that.currency,_that.authType);case _:
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -221,7 +222,7 @@ return $default(_that.name,_that.description,_that.image,_that.accountFieldName,
|
|||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
|
|
||||||
class _PaymentProcessor with DiagnosticableTreeMixin implements PaymentProcessor {
|
class _PaymentProcessor with DiagnosticableTreeMixin implements PaymentProcessor {
|
||||||
const _PaymentProcessor({required this.name, required this.description, required this.image, required this.accountFieldName, required this.accountFieldLabel, required this.label, required this.authType});
|
const _PaymentProcessor({required this.name, required this.description, required this.image, required this.accountFieldName, required this.accountFieldLabel, required this.label, required this.currency, required this.authType});
|
||||||
factory _PaymentProcessor.fromJson(Map<String, dynamic> json) => _$PaymentProcessorFromJson(json);
|
factory _PaymentProcessor.fromJson(Map<String, dynamic> json) => _$PaymentProcessorFromJson(json);
|
||||||
|
|
||||||
@override final String name;
|
@override final String name;
|
||||||
@@ -230,6 +231,7 @@ class _PaymentProcessor with DiagnosticableTreeMixin implements PaymentProcessor
|
|||||||
@override final String accountFieldName;
|
@override final String accountFieldName;
|
||||||
@override final String accountFieldLabel;
|
@override final String accountFieldLabel;
|
||||||
@override final String label;
|
@override final String label;
|
||||||
|
@override final String currency;
|
||||||
@override final String authType;
|
@override final String authType;
|
||||||
|
|
||||||
/// Create a copy of PaymentProcessor
|
/// Create a copy of PaymentProcessor
|
||||||
@@ -246,21 +248,21 @@ Map<String, dynamic> toJson() {
|
|||||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||||
properties
|
properties
|
||||||
..add(DiagnosticsProperty('type', 'PaymentProcessor'))
|
..add(DiagnosticsProperty('type', 'PaymentProcessor'))
|
||||||
..add(DiagnosticsProperty('name', name))..add(DiagnosticsProperty('description', description))..add(DiagnosticsProperty('image', image))..add(DiagnosticsProperty('accountFieldName', accountFieldName))..add(DiagnosticsProperty('accountFieldLabel', accountFieldLabel))..add(DiagnosticsProperty('label', label))..add(DiagnosticsProperty('authType', authType));
|
..add(DiagnosticsProperty('name', name))..add(DiagnosticsProperty('description', description))..add(DiagnosticsProperty('image', image))..add(DiagnosticsProperty('accountFieldName', accountFieldName))..add(DiagnosticsProperty('accountFieldLabel', accountFieldLabel))..add(DiagnosticsProperty('label', label))..add(DiagnosticsProperty('currency', currency))..add(DiagnosticsProperty('authType', authType));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PaymentProcessor&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.image, image) || other.image == image)&&(identical(other.accountFieldName, accountFieldName) || other.accountFieldName == accountFieldName)&&(identical(other.accountFieldLabel, accountFieldLabel) || other.accountFieldLabel == accountFieldLabel)&&(identical(other.label, label) || other.label == label)&&(identical(other.authType, authType) || other.authType == authType));
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PaymentProcessor&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.image, image) || other.image == image)&&(identical(other.accountFieldName, accountFieldName) || other.accountFieldName == accountFieldName)&&(identical(other.accountFieldLabel, accountFieldLabel) || other.accountFieldLabel == accountFieldLabel)&&(identical(other.label, label) || other.label == label)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.authType, authType) || other.authType == authType));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(runtimeType,name,description,image,accountFieldName,accountFieldLabel,label,authType);
|
int get hashCode => Object.hash(runtimeType,name,description,image,accountFieldName,accountFieldLabel,label,currency,authType);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
|
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
|
||||||
return 'PaymentProcessor(name: $name, description: $description, image: $image, accountFieldName: $accountFieldName, accountFieldLabel: $accountFieldLabel, label: $label, authType: $authType)';
|
return 'PaymentProcessor(name: $name, description: $description, image: $image, accountFieldName: $accountFieldName, accountFieldLabel: $accountFieldLabel, label: $label, currency: $currency, authType: $authType)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -271,7 +273,7 @@ abstract mixin class _$PaymentProcessorCopyWith<$Res> implements $PaymentProcess
|
|||||||
factory _$PaymentProcessorCopyWith(_PaymentProcessor value, $Res Function(_PaymentProcessor) _then) = __$PaymentProcessorCopyWithImpl;
|
factory _$PaymentProcessorCopyWith(_PaymentProcessor value, $Res Function(_PaymentProcessor) _then) = __$PaymentProcessorCopyWithImpl;
|
||||||
@override @useResult
|
@override @useResult
|
||||||
$Res call({
|
$Res call({
|
||||||
String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String authType
|
String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String currency, String authType
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -288,7 +290,7 @@ class __$PaymentProcessorCopyWithImpl<$Res>
|
|||||||
|
|
||||||
/// Create a copy of PaymentProcessor
|
/// Create a copy of PaymentProcessor
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? description = null,Object? image = null,Object? accountFieldName = null,Object? accountFieldLabel = null,Object? label = null,Object? authType = null,}) {
|
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? description = null,Object? image = null,Object? accountFieldName = null,Object? accountFieldLabel = null,Object? label = null,Object? currency = null,Object? authType = null,}) {
|
||||||
return _then(_PaymentProcessor(
|
return _then(_PaymentProcessor(
|
||||||
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
||||||
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||||
@@ -296,6 +298,7 @@ as String,image: null == image ? _self.image : image // ignore: cast_nullable_to
|
|||||||
as String,accountFieldName: null == accountFieldName ? _self.accountFieldName : accountFieldName // ignore: cast_nullable_to_non_nullable
|
as String,accountFieldName: null == accountFieldName ? _self.accountFieldName : accountFieldName // ignore: cast_nullable_to_non_nullable
|
||||||
as String,accountFieldLabel: null == accountFieldLabel ? _self.accountFieldLabel : accountFieldLabel // ignore: cast_nullable_to_non_nullable
|
as String,accountFieldLabel: null == accountFieldLabel ? _self.accountFieldLabel : accountFieldLabel // ignore: cast_nullable_to_non_nullable
|
||||||
as String,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
|
as String,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,currency: null == currency ? _self.currency : currency // ignore: cast_nullable_to_non_nullable
|
||||||
as String,authType: null == authType ? _self.authType : authType // ignore: cast_nullable_to_non_nullable
|
as String,authType: null == authType ? _self.authType : authType // ignore: cast_nullable_to_non_nullable
|
||||||
as String,
|
as String,
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ _PaymentProcessor _$PaymentProcessorFromJson(Map<String, dynamic> json) =>
|
|||||||
accountFieldName: json['accountFieldName'] as String,
|
accountFieldName: json['accountFieldName'] as String,
|
||||||
accountFieldLabel: json['accountFieldLabel'] as String,
|
accountFieldLabel: json['accountFieldLabel'] as String,
|
||||||
label: json['label'] as String,
|
label: json['label'] as String,
|
||||||
|
currency: json['currency'] as String,
|
||||||
authType: json['authType'] as String,
|
authType: json['authType'] as String,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ Map<String, dynamic> _$PaymentProcessorToJson(_PaymentProcessor instance) =>
|
|||||||
'accountFieldName': instance.accountFieldName,
|
'accountFieldName': instance.accountFieldName,
|
||||||
'accountFieldLabel': instance.accountFieldLabel,
|
'accountFieldLabel': instance.accountFieldLabel,
|
||||||
'label': instance.label,
|
'label': instance.label,
|
||||||
|
'currency': instance.currency,
|
||||||
'authType': instance.authType,
|
'authType': instance.authType,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter_contacts/flutter_contacts.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
|
import 'package:qpay/screens/accounts/account_provider.dart';
|
||||||
import 'package:qpay/screens/pay/pay_controller.dart';
|
import 'package:qpay/screens/pay/pay_controller.dart';
|
||||||
import 'package:qpay/screens/transaction_controller.dart';
|
import 'package:qpay/screens/transaction_controller.dart';
|
||||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
@@ -64,7 +65,6 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
|||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
controller = PayController(context);
|
controller = PayController(context);
|
||||||
controller.getPaymentProcessors();
|
|
||||||
|
|
||||||
controller.getProducts(controller.model.selectedProvider?.id ?? "");
|
controller.getProducts(controller.model.selectedProvider?.id ?? "");
|
||||||
transactionController = Provider.of<TransactionController>(
|
transactionController = Provider.of<TransactionController>(
|
||||||
@@ -72,6 +72,7 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
|||||||
listen: false,
|
listen: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
controller.getPaymentProcessors(transactionController.model.currency);
|
||||||
_setupData();
|
_setupData();
|
||||||
|
|
||||||
_controller = AnimationController(
|
_controller = AnimationController(
|
||||||
@@ -234,7 +235,22 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildTransactionDetailsCard(
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
// Look up City Wallet balance matching the
|
||||||
|
// transaction currency from the shared
|
||||||
|
// AccountProvider.
|
||||||
|
final accountProvider = context
|
||||||
|
.watch<AccountProvider>();
|
||||||
|
final currency =
|
||||||
|
transactionController.model.currency;
|
||||||
|
final cityWallet = accountProvider
|
||||||
|
.balanceForCurrency(currency);
|
||||||
|
final hasCityWallet =
|
||||||
|
cityWallet != null &&
|
||||||
|
cityWallet.name == 'City Wallet';
|
||||||
|
|
||||||
|
return _buildTransactionDetailsCard(
|
||||||
children: [
|
children: [
|
||||||
_buildDetailRow(
|
_buildDetailRow(
|
||||||
icon: Icons.business_outlined,
|
icon: Icons.business_outlined,
|
||||||
@@ -262,10 +278,28 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
|||||||
_buildDetailRow(
|
_buildDetailRow(
|
||||||
icon: Icons.monetization_on,
|
icon: Icons.monetization_on,
|
||||||
label: "Currency",
|
label: "Currency",
|
||||||
|
value: transactionController
|
||||||
|
.model
|
||||||
|
.currency,
|
||||||
|
),
|
||||||
|
if (hasCityWallet)
|
||||||
|
_buildDetailRow(
|
||||||
|
icon: Icons
|
||||||
|
.account_balance_wallet_outlined,
|
||||||
|
label: "City Wallet Balance",
|
||||||
value:
|
value:
|
||||||
transactionController.model.currency,
|
'\$${cityWallet!.balance.toStringAsFixed(2)}',
|
||||||
|
valueStyle: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
SizedBox(height: 20),
|
SizedBox(height: 20),
|
||||||
InkWell(
|
InkWell(
|
||||||
@@ -748,6 +782,7 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
|||||||
required IconData icon,
|
required IconData icon,
|
||||||
required String label,
|
required String label,
|
||||||
required String value,
|
required String value,
|
||||||
|
TextStyle? valueStyle,
|
||||||
}) {
|
}) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
@@ -762,7 +797,9 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
|||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style: TextStyle(
|
style:
|
||||||
|
valueStyle ??
|
||||||
|
TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.black87,
|
color: Colors.black87,
|
||||||
|
|||||||
@@ -144,13 +144,17 @@ class ProviderController extends ChangeNotifier {
|
|||||||
|
|
||||||
// ── Payment Processors ─────────────────────────────────────
|
// ── Payment Processors ─────────────────────────────────────
|
||||||
|
|
||||||
Future<ApiResponse<List<PaymentProcessor>>> loadProcessors() async {
|
Future<ApiResponse<List<PaymentProcessor>>> loadProcessors(
|
||||||
|
String currency,
|
||||||
|
) async {
|
||||||
model.isLoadingProcessors = true;
|
model.isLoadingProcessors = true;
|
||||||
model.errorMessage = null;
|
model.errorMessage = null;
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final raw = await http.get('/public/payment-processors');
|
final raw = await http.get(
|
||||||
|
'/public/payment-processors?currency=$currency',
|
||||||
|
);
|
||||||
final response = _unwrap(raw);
|
final response = _unwrap(raw);
|
||||||
final List<dynamic> list = response is List
|
final List<dynamic> list = response is List
|
||||||
? response
|
? response
|
||||||
|
|||||||
@@ -404,9 +404,21 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20),
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text('Need help? '),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.push('/contact'),
|
||||||
|
child: Text('Get in touch'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// Status badge
|
// Status badge
|
||||||
if (status != null) ...[
|
if (status != null) ...[
|
||||||
_buildStatusBadge(status, theme),
|
_buildStatusBadge(status, theme),
|
||||||
|
|||||||
@@ -216,7 +216,8 @@ class _TransactionItemWidgetState extends State<TransactionItemWidget> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Quick actions row (only shown when onRepeat is provided)
|
// Quick actions row (only shown when onRepeat is provided)
|
||||||
if (widget.onRepeat != null) ...[
|
if (widget.onRepeat != null &&
|
||||||
|
widget.transaction['batchId'] == null) ...[
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Divider(
|
Divider(
|
||||||
height: 1,
|
height: 1,
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ http {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||||
|
|
||||||
# Gzip compression
|
# Gzip compression
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
||||||
|
|||||||
@@ -577,13 +577,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
path_provider:
|
path_provider:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.5"
|
version: "2.1.6"
|
||||||
path_provider_android:
|
path_provider_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ dependencies:
|
|||||||
gif_view: ^0.4.0
|
gif_view: ^0.4.0
|
||||||
firebase_core: ^4.1.0
|
firebase_core: ^4.1.0
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
|
path_provider: ^2.1.6
|
||||||
html: ^0.15.6
|
html: ^0.15.6
|
||||||
web: ^1.1.1
|
web: ^1.1.1
|
||||||
flutter_web_plugins:
|
flutter_web_plugins:
|
||||||
|
|||||||
Reference in New Issue
Block a user