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
|
||||
|
@@ -145,4 +145,4 @@ class Http {
|
||||
logger.i(response.data.toString());
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
||||
import 'package:qpay/providers/splash_provider.dart';
|
||||
import 'package:qpay/screens/workspaces/workspace_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:firebase_core/firebase_core.dart';
|
||||
import 'firebase_options.dart';
|
||||
@@ -36,14 +37,13 @@ void main() async {
|
||||
ChangeNotifierProvider<OnboardingController>(
|
||||
create: (_) => OnboardingController(),
|
||||
),
|
||||
ChangeNotifierProvider<SplashProvider>(
|
||||
create: (_) => SplashProvider(),
|
||||
),
|
||||
ChangeNotifierProvider<SplashProvider>(create: (_) => SplashProvider()),
|
||||
ChangeNotifierProvider<WorkspaceProvider>(
|
||||
create: (_) => WorkspaceProvider(),
|
||||
),
|
||||
ChangeNotifierProvider<UptimeProvider>(
|
||||
create: (_) => UptimeProvider(),
|
||||
ChangeNotifierProvider<UptimeProvider>(create: (_) => UptimeProvider()),
|
||||
ChangeNotifierProvider<AccountProvider>(
|
||||
create: (_) => AccountProvider(),
|
||||
),
|
||||
],
|
||||
child: const MyApp(),
|
||||
@@ -69,4 +69,4 @@ class _MyAppState extends State<MyApp> {
|
||||
routerConfig: buildRouter(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/more/more_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
|
||||
/// GoRouter's redirect can show the splash first, then release
|
||||
@@ -232,6 +233,10 @@ GoRouter buildRouter() {
|
||||
path: '/more',
|
||||
builder: (context, state) => const MoreScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/contact',
|
||||
builder: (context, state) => const ContactScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/uptime',
|
||||
builder: (context, state) => const UptimeStatusScreen(),
|
||||
|
||||
@@ -17,6 +17,9 @@ class AccountProvider extends ChangeNotifier {
|
||||
String? _accountError;
|
||||
String? _statementError;
|
||||
|
||||
/// Cached balances keyed by currency code (e.g. 'USD', 'ZWG').
|
||||
final Map<String, AccountModel?> _balances = {};
|
||||
|
||||
AccountModel? get account => _account;
|
||||
StatementModel? get statement => _statement;
|
||||
bool get isLoadingAccount => _isLoadingAccount;
|
||||
@@ -25,6 +28,20 @@ class AccountProvider extends ChangeNotifier {
|
||||
String? get statementError => _statementError;
|
||||
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
|
||||
void notifyListeners() {
|
||||
if (!_isDisposed) {
|
||||
@@ -52,6 +69,7 @@ class AccountProvider extends ChangeNotifier {
|
||||
);
|
||||
|
||||
_account = AccountModel.fromJson(response);
|
||||
_balances[currency.toUpperCase()] = _account;
|
||||
_isLoadingAccount = false;
|
||||
notifyListeners();
|
||||
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({
|
||||
required String workspaceId,
|
||||
required String currency,
|
||||
@@ -117,4 +146,4 @@ class AccountProvider extends ChangeNotifier {
|
||||
_statementError = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qpay/screens/accounts/account_provider.dart';
|
||||
import 'package:qpay/screens/accounts/models/account_model.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -20,8 +21,6 @@ class AccountBalanceWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
|
||||
late AccountProvider _accountProvider;
|
||||
|
||||
bool _isLoggedIn = false;
|
||||
String? _workspaceId;
|
||||
|
||||
@@ -36,20 +35,12 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_accountProvider = AccountProvider();
|
||||
_loadAuthStateAndBalances();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_accountProvider.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadAuthStateAndBalances() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Determine login state from persisted token
|
||||
if (prefs.getString("token") != null) {
|
||||
setState(() {
|
||||
_isLoggedIn = true;
|
||||
@@ -62,18 +53,37 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isLoggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final accountProvider = context.read<AccountProvider>();
|
||||
|
||||
setState(() {
|
||||
_isLoadingUsd = true;
|
||||
_isLoadingZwG = true;
|
||||
});
|
||||
|
||||
// no need to proceed if not logged in
|
||||
if (!_isLoggedIn) {
|
||||
// Try the cached balances first
|
||||
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;
|
||||
}
|
||||
|
||||
// Fetch USD balance
|
||||
final usdResult = await _accountProvider.fetchAccount(_workspaceId, 'USD');
|
||||
final usdResult = await accountProvider.fetchAccount(_workspaceId, 'USD');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingUsd = false;
|
||||
@@ -87,7 +97,7 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
|
||||
}
|
||||
|
||||
// Fetch ZWG balance
|
||||
final zwgResult = await _accountProvider.fetchAccount(_workspaceId, 'ZWG');
|
||||
final zwgResult = await accountProvider.fetchAccount(_workspaceId, 'ZWG');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingZwG = false;
|
||||
@@ -117,12 +127,16 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
|
||||
|
||||
if (_workspaceId == null || _workspaceId!.isEmpty) return;
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final accountProvider = context.read<AccountProvider>();
|
||||
|
||||
setState(() {
|
||||
_isLoadingUsd = true;
|
||||
_isLoadingZwG = true;
|
||||
});
|
||||
|
||||
final usdResult = await _accountProvider.fetchAccount(_workspaceId, 'USD');
|
||||
final usdResult = await accountProvider.fetchAccount(_workspaceId, 'USD');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_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) {
|
||||
setState(() {
|
||||
_isLoadingZwG = false;
|
||||
@@ -509,4 +523,4 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
|
||||
final formatter = NumberFormat('#,##0.00', 'en_US');
|
||||
return '\$${formatter.format(balance)}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.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/widgets/app_snack_bar.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
@@ -600,42 +602,112 @@ class _ConfirmScreenState extends State<ConfirmScreen>
|
||||
onTap: () async {
|
||||
_handlePayment();
|
||||
},
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Image(
|
||||
image: AssetImage(
|
||||
"assets/${controller.transactionController.model.selectedPaymentProcessor.image}",
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Image(
|
||||
image: AssetImage(
|
||||
"assets/${controller.transactionController.model.selectedPaymentProcessor.image}",
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
controller
|
||||
.transactionController
|
||||
.model
|
||||
.selectedPaymentProcessor
|
||||
.name,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
controller
|
||||
.transactionController
|
||||
.model
|
||||
.selectedPaymentProcessor
|
||||
.description,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.chevron_right,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.secondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
controller
|
||||
.transactionController
|
||||
.model
|
||||
.selectedPaymentProcessor
|
||||
.name,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
controller
|
||||
.transactionController
|
||||
.model
|
||||
.selectedPaymentProcessor
|
||||
.description,
|
||||
style: TextStyle(fontWeight: FontWeight.normal, fontSize: 10),
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.chevron_right,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).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 {
|
||||
final raw = await http.get('/public/payment-processors');
|
||||
final raw = await http.get(
|
||||
'/public/payment-processors?currency=$currency',
|
||||
);
|
||||
final response = _unwrap(raw);
|
||||
final List<dynamic> content = response is List
|
||||
? response
|
||||
|
||||
@@ -62,6 +62,7 @@ class GroupBatchItem {
|
||||
final String? billProductName;
|
||||
final String? providerLabel;
|
||||
final String? providerImage;
|
||||
final String? creditAddress;
|
||||
|
||||
const GroupBatchItem({
|
||||
this.id,
|
||||
@@ -78,6 +79,7 @@ class GroupBatchItem {
|
||||
this.billProductName,
|
||||
this.providerLabel,
|
||||
this.providerImage,
|
||||
this.creditAddress,
|
||||
});
|
||||
|
||||
factory GroupBatchItem.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -62,7 +62,7 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
||||
}
|
||||
await Future.wait([
|
||||
providerController.loadProviders(_currency),
|
||||
batchController.loadProcessors(),
|
||||
batchController.loadProcessors(_currency),
|
||||
]);
|
||||
if (mounted) setState(() => _loadingDropdowns = false);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.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/groups/models/batch_item_update_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 {
|
||||
final String batchId;
|
||||
|
||||
const BatchDetailScreen({
|
||||
super.key,
|
||||
required this.batchId,
|
||||
});
|
||||
const BatchDetailScreen({super.key, required this.batchId});
|
||||
|
||||
@override
|
||||
State<BatchDetailScreen> createState() => _BatchDetailScreenState();
|
||||
@@ -59,7 +58,6 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = BatchController(context);
|
||||
controller.loadProcessors();
|
||||
|
||||
_entryController = AnimationController(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
@@ -96,6 +94,8 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
setState(() {
|
||||
_initialLoading = false;
|
||||
});
|
||||
|
||||
controller.loadProcessors(_batch!.currency);
|
||||
} else {
|
||||
setState(() {
|
||||
_initialLoading = false;
|
||||
@@ -337,55 +337,119 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
child: Column(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.asset(
|
||||
'assets/${processor.image}',
|
||||
width: 36,
|
||||
height: 36,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
Icons.payment_rounded,
|
||||
size: 28,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
processor.name,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark
|
||||
? Colors.white
|
||||
: Colors.black87,
|
||||
Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
8,
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/${processor.image}',
|
||||
width: 36,
|
||||
height: 36,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
Icons.payment_rounded,
|
||||
size: 28,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
if (processor.label.isNotEmpty)
|
||||
Text(
|
||||
processor.label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
processor.name,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark
|
||||
? Colors.white
|
||||
: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (processor.label.isNotEmpty)
|
||||
Text(
|
||||
processor.label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
isSelected
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: Colors.grey.shade400,
|
||||
),
|
||||
],
|
||||
),
|
||||
Icon(
|
||||
isSelected
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: Colors.grey.shade400,
|
||||
// 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),
|
||||
if (item.billName != null || item.billProductName != null)
|
||||
_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/services.dart' show rootBundle;
|
||||
import 'package:file_picker/file_picker.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/models/create_group_from_file_response.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class CreateGroupFromFileScreen extends StatefulWidget {
|
||||
const CreateGroupFromFileScreen({super.key});
|
||||
@@ -38,6 +44,40 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
||||
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 {
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.custom,
|
||||
@@ -95,9 +135,7 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
||||
);
|
||||
|
||||
if (groupId.isNotEmpty && batch.id != null) {
|
||||
context.pushReplacement(
|
||||
'/groups/$groupId/batches/${batch.id}',
|
||||
);
|
||||
context.pushReplacement('/groups/batches/${batch.id}');
|
||||
}
|
||||
} else {
|
||||
// Partial failure — show errors
|
||||
@@ -122,9 +160,7 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(24),
|
||||
),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||
child: Column(
|
||||
@@ -144,8 +180,11 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded,
|
||||
color: Colors.orange.shade700, size: 24),
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Colors.orange.shade700,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Upload completed with errors',
|
||||
@@ -161,18 +200,14 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
||||
Text(
|
||||
'${response.errorCount} of ${response.totalRows} rows failed. '
|
||||
'${response.successCount} rows succeeded.',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (response.errors.isNotEmpty) ...[
|
||||
Flexible(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight:
|
||||
MediaQuery.of(sheetContext).size.height * 0.4,
|
||||
maxHeight: MediaQuery.of(sheetContext).size.height * 0.4,
|
||||
),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
@@ -241,10 +276,7 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Create from File'),
|
||||
centerTitle: true,
|
||||
),
|
||||
appBar: AppBar(title: const Text('Create from File'), centerTitle: true),
|
||||
body: ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) {
|
||||
@@ -272,10 +304,9 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty)
|
||||
? 'Group name is required'
|
||||
: null,
|
||||
validator: (v) => (v == null || v.trim().isEmpty)
|
||||
? 'Group name is required'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
@@ -344,10 +375,9 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: _fileBytes != null
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withAlpha(12)
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withAlpha(12)
|
||||
: Colors.grey.shade50,
|
||||
),
|
||||
child: Column(
|
||||
@@ -363,14 +393,15 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_selectedFileName ?? 'Tap to select CSV file',
|
||||
_selectedFileName ??
|
||||
'Tap to select CSV file',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _fileBytes != null
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary
|
||||
: Colors.grey.shade600,
|
||||
),
|
||||
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),
|
||||
ElevatedButton(
|
||||
onPressed: controller.model.isLoading
|
||||
? null
|
||||
: _submit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 16),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
@@ -427,4 +476,4 @@ class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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/models/api_response.dart';
|
||||
import 'package:qpay/models/pageable_model.dart';
|
||||
@@ -183,6 +186,58 @@ class HistoryController extends ChangeNotifier {
|
||||
_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() {
|
||||
model.statusFilter = null;
|
||||
model.typeFilter = null;
|
||||
@@ -219,4 +274,4 @@ class HistoryController extends ChangeNotifier {
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qpay/auth_state.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/receipt/receipt_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
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
@@ -396,6 +534,15 @@ class _HistoryScreenState extends State<HistoryScreen>
|
||||
},
|
||||
),
|
||||
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(
|
||||
icon: Icons.add,
|
||||
tooltip: 'New payment',
|
||||
@@ -679,4 +826,4 @@ class _HistoryScreenState extends State<HistoryScreen>
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../auth_state.dart';
|
||||
import '../../models/responsive_policy.dart';
|
||||
|
||||
class MoreScreen extends StatelessWidget {
|
||||
class MoreScreen extends StatefulWidget {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -22,6 +72,12 @@ class MoreScreen extends StatelessWidget {
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_NavOptionTile(
|
||||
icon: Icons.swap_horiz,
|
||||
title: 'Change Workspace',
|
||||
subtitle: 'Switch to a different workspace',
|
||||
onTap: () => context.go('/workspace'),
|
||||
),
|
||||
_NavOptionTile(
|
||||
icon: Icons.people_outline,
|
||||
title: 'Users',
|
||||
@@ -34,6 +90,18 @@ class MoreScreen extends StatelessWidget {
|
||||
subtitle: 'View system uptime and service status',
|
||||
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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -77,4 +145,4 @@ class _NavOptionTile extends StatelessWidget {
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ abstract class PaymentProcessor with _$PaymentProcessor {
|
||||
required String accountFieldName,
|
||||
required String accountFieldLabel,
|
||||
required String label,
|
||||
required String currency,
|
||||
required String authType,
|
||||
}) = _PaymentProcessor;
|
||||
|
||||
@@ -176,15 +177,22 @@ class PayController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ApiResponse<List<PaymentProcessor>>> getPaymentProcessors() async {
|
||||
Future<ApiResponse<List<PaymentProcessor>>> getPaymentProcessors(
|
||||
String currency,
|
||||
) async {
|
||||
try {
|
||||
model.isLoading = true;
|
||||
model.paymentProcessors = getFakePaymentProcessors();
|
||||
notifyListeners();
|
||||
|
||||
List<dynamic> response = await http.get('/public/payment-processors');
|
||||
model.paymentProcessors = response
|
||||
.map((e) => PaymentProcessor.fromJson(e))
|
||||
final response = await http.get(
|
||||
'/public/payment-processors?currency=$currency',
|
||||
);
|
||||
final List<dynamic> content = response is List
|
||||
? response
|
||||
: (response['content'] ?? []);
|
||||
model.paymentProcessors = content
|
||||
.map((e) => PaymentProcessor.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
model.isLoading = false;
|
||||
@@ -238,6 +246,7 @@ class PayController extends ChangeNotifier {
|
||||
accountFieldLabel: "EcoCash Phone Number",
|
||||
label: "ECOCASH",
|
||||
authType: "REMOTE",
|
||||
currency: "USD",
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
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
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -29,21 +29,21 @@ $PaymentProcessorCopyWith<PaymentProcessor> get copyWith => _$PaymentProcessorCo
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
properties
|
||||
..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
|
||||
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)
|
||||
@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
|
||||
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;
|
||||
@useResult
|
||||
$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
|
||||
/// 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(
|
||||
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
|
||||
@@ -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,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,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,
|
||||
));
|
||||
@@ -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) {
|
||||
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();
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
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');
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
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;
|
||||
|
||||
}
|
||||
@@ -221,7 +222,7 @@ return $default(_that.name,_that.description,_that.image,_that.accountFieldName,
|
||||
@JsonSerializable()
|
||||
|
||||
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);
|
||||
|
||||
@override final String name;
|
||||
@@ -230,6 +231,7 @@ class _PaymentProcessor with DiagnosticableTreeMixin implements PaymentProcessor
|
||||
@override final String accountFieldName;
|
||||
@override final String accountFieldLabel;
|
||||
@override final String label;
|
||||
@override final String currency;
|
||||
@override final String authType;
|
||||
|
||||
/// Create a copy of PaymentProcessor
|
||||
@@ -246,21 +248,21 @@ Map<String, dynamic> toJson() {
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
properties
|
||||
..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
|
||||
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)
|
||||
@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
|
||||
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;
|
||||
@override @useResult
|
||||
$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
|
||||
/// 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(
|
||||
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
|
||||
@@ -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,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,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,
|
||||
));
|
||||
|
||||
@@ -14,6 +14,7 @@ _PaymentProcessor _$PaymentProcessorFromJson(Map<String, dynamic> json) =>
|
||||
accountFieldName: json['accountFieldName'] as String,
|
||||
accountFieldLabel: json['accountFieldLabel'] as String,
|
||||
label: json['label'] as String,
|
||||
currency: json['currency'] as String,
|
||||
authType: json['authType'] as String,
|
||||
);
|
||||
|
||||
@@ -25,6 +26,7 @@ Map<String, dynamic> _$PaymentProcessorToJson(_PaymentProcessor instance) =>
|
||||
'accountFieldName': instance.accountFieldName,
|
||||
'accountFieldLabel': instance.accountFieldLabel,
|
||||
'label': instance.label,
|
||||
'currency': instance.currency,
|
||||
'authType': instance.authType,
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_contacts/flutter_contacts.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.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/transaction_controller.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
@@ -64,7 +65,6 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
||||
super.initState();
|
||||
|
||||
controller = PayController(context);
|
||||
controller.getPaymentProcessors();
|
||||
|
||||
controller.getProducts(controller.model.selectedProvider?.id ?? "");
|
||||
transactionController = Provider.of<TransactionController>(
|
||||
@@ -72,6 +72,7 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
||||
listen: false,
|
||||
);
|
||||
|
||||
controller.getPaymentProcessors(transactionController.model.currency);
|
||||
_setupData();
|
||||
|
||||
_controller = AnimationController(
|
||||
@@ -234,38 +235,71 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildTransactionDetailsCard(
|
||||
children: [
|
||||
_buildDetailRow(
|
||||
icon: Icons.business_outlined,
|
||||
label: "Provider",
|
||||
value:
|
||||
controller
|
||||
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: [
|
||||
_buildDetailRow(
|
||||
icon: Icons.business_outlined,
|
||||
label: "Provider",
|
||||
value:
|
||||
controller
|
||||
.model
|
||||
.selectedProvider
|
||||
?.name ??
|
||||
"",
|
||||
),
|
||||
_buildDetailRow(
|
||||
icon: Icons.account_balance_outlined,
|
||||
label:
|
||||
controller
|
||||
.model
|
||||
.selectedProvider
|
||||
?.accountFieldName ??
|
||||
"",
|
||||
value: transactionController
|
||||
.model
|
||||
.selectedProvider
|
||||
?.name ??
|
||||
"",
|
||||
),
|
||||
_buildDetailRow(
|
||||
icon: Icons.account_balance_outlined,
|
||||
label:
|
||||
controller
|
||||
.formData
|
||||
.creditAccount,
|
||||
),
|
||||
_buildDetailRow(
|
||||
icon: Icons.monetization_on,
|
||||
label: "Currency",
|
||||
value: transactionController
|
||||
.model
|
||||
.selectedProvider
|
||||
?.accountFieldName ??
|
||||
"",
|
||||
value: transactionController
|
||||
.model
|
||||
.formData
|
||||
.creditAccount,
|
||||
),
|
||||
_buildDetailRow(
|
||||
icon: Icons.monetization_on,
|
||||
label: "Currency",
|
||||
value:
|
||||
transactionController.model.currency,
|
||||
),
|
||||
],
|
||||
.currency,
|
||||
),
|
||||
if (hasCityWallet)
|
||||
_buildDetailRow(
|
||||
icon: Icons
|
||||
.account_balance_wallet_outlined,
|
||||
label: "City Wallet Balance",
|
||||
value:
|
||||
'\$${cityWallet!.balance.toStringAsFixed(2)}',
|
||||
valueStyle: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
InkWell(
|
||||
@@ -748,6 +782,7 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String value,
|
||||
TextStyle? valueStyle,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
@@ -762,11 +797,13 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
||||
const Spacer(),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
style:
|
||||
valueStyle ??
|
||||
TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -144,13 +144,17 @@ class ProviderController extends ChangeNotifier {
|
||||
|
||||
// ── Payment Processors ─────────────────────────────────────
|
||||
|
||||
Future<ApiResponse<List<PaymentProcessor>>> loadProcessors() async {
|
||||
Future<ApiResponse<List<PaymentProcessor>>> loadProcessors(
|
||||
String currency,
|
||||
) async {
|
||||
model.isLoadingProcessors = true;
|
||||
model.errorMessage = null;
|
||||
if (!_disposed) notifyListeners();
|
||||
|
||||
try {
|
||||
final raw = await http.get('/public/payment-processors');
|
||||
final raw = await http.get(
|
||||
'/public/payment-processors?currency=$currency',
|
||||
);
|
||||
final response = _unwrap(raw);
|
||||
final List<dynamic> list = response is List
|
||||
? 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(
|
||||
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
|
||||
if (status != null) ...[
|
||||
_buildStatusBadge(status, theme),
|
||||
|
||||
@@ -216,7 +216,8 @@ class _TransactionItemWidgetState extends State<TransactionItemWidget> {
|
||||
),
|
||||
|
||||
// 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),
|
||||
Divider(
|
||||
height: 1,
|
||||
|
||||
@@ -12,6 +12,12 @@ http {
|
||||
root /usr/share/nginx/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 on;
|
||||
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
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
version: "2.1.6"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -54,6 +54,7 @@ dependencies:
|
||||
gif_view: ^0.4.0
|
||||
firebase_core: ^4.1.0
|
||||
url_launcher: ^6.3.2
|
||||
path_provider: ^2.1.6
|
||||
html: ^0.15.6
|
||||
web: ^1.1.1
|
||||
flutter_web_plugins:
|
||||
|
||||
Reference in New Issue
Block a user