completed first web iteration

This commit is contained in:
2025-11-25 20:06:33 +02:00
parent 895925d32f
commit 32b383afa9
41 changed files with 3703 additions and 2613 deletions

View File

@@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited. # This file should be version controlled and should not be manually edited.
version: version:
revision: "ea121f8859e4b13e47a8f845e4586164519588bc" revision: "9f455d2486bcb28cad87b062475f42edc959f636"
channel: "stable" channel: "stable"
project_type: app project_type: app
@@ -13,26 +13,11 @@ project_type: app
migration: migration:
platforms: platforms:
- platform: root - platform: root
create_revision: ea121f8859e4b13e47a8f845e4586164519588bc create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: ea121f8859e4b13e47a8f845e4586164519588bc base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: android
create_revision: ea121f8859e4b13e47a8f845e4586164519588bc
base_revision: ea121f8859e4b13e47a8f845e4586164519588bc
- platform: ios
create_revision: ea121f8859e4b13e47a8f845e4586164519588bc
base_revision: ea121f8859e4b13e47a8f845e4586164519588bc
- platform: linux
create_revision: ea121f8859e4b13e47a8f845e4586164519588bc
base_revision: ea121f8859e4b13e47a8f845e4586164519588bc
- platform: macos
create_revision: ea121f8859e4b13e47a8f845e4586164519588bc
base_revision: ea121f8859e4b13e47a8f845e4586164519588bc
- platform: web - platform: web
create_revision: ea121f8859e4b13e47a8f845e4586164519588bc create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: ea121f8859e4b13e47a8f845e4586164519588bc base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: windows
create_revision: ea121f8859e4b13e47a8f845e4586164519588bc
base_revision: ea121f8859e4b13e47a8f845e4586164519588bc
# User provided section # User provided section

71
Dockerfile Normal file
View File

@@ -0,0 +1,71 @@
# syntax=docker/dockerfile:1.6
###############################################
# Build stage: compile Flutter web artifacts. #
###############################################
FROM ubuntu:22.04 AS build
ENV DEBIAN_FRONTEND=noninteractive \
FLUTTER_CHANNEL=stable \
FLUTTER_VERSION=3.24.3 \
FLUTTER_HOME=/opt/flutter
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
unzip \
xz-utils \
zip \
libglu1-mesa && \
rm -rf /var/lib/apt/lists/*
RUN curl -fsSL "https://storage.googleapis.com/flutter_infra_release/releases/${FLUTTER_CHANNEL}/linux/flutter_linux_${FLUTTER_VERSION}-${FLUTTER_CHANNEL}.tar.xz" \
| tar -xJ -C /opt && \
ln -s ${FLUTTER_HOME} /usr/local/flutter
ENV PATH="${FLUTTER_HOME}/bin:${FLUTTER_HOME}/bin/cache/dart-sdk/bin:${PATH}"
RUN flutter config --enable-web && \
flutter doctor -v
WORKDIR /app
COPY pubspec.yaml pubspec.lock ./
RUN flutter pub get
COPY . .
RUN flutter build web --release
##########################################
# Runtime stage: serve assets via nginx. #
##########################################
FROM ubuntu:22.04 AS runner
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install -y --no-install-recommends nginx && \
rm -rf /var/lib/apt/lists/*
# Remove default site definition to avoid duplicate configs.
RUN rm -f /etc/nginx/sites-enabled/default
# Provide a minimal nginx site config for the Flutter web assets.
RUN printf 'server {\n\
listen 80 default_server;\n\
listen [::]:80 default_server;\n\
root /var/www/html;\n\
index index.html;\n\
location / {\n\
try_files $uri $uri/ /index.html;\n\
}\n\
}\n' > /etc/nginx/conf.d/flutter.conf
COPY --from=build /app/build/web /var/www/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -3,7 +3,7 @@
<uses-permission android:name="android.permission.READ_CONTACTS"/> <uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/> <uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<application <application
android:label="Peak" android:label="Velocity"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:forceDarkAllowed="false"> android:forceDarkAllowed="false">

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Peak</string> <string name="app_name">Velocity</string>
</resources> </resources>

View File

@@ -1,6 +1,6 @@
BASE_URL=https://peakapi.qantra.co.zw/api BASE_URL=https://peakapi.qantra.co.zw/api
; BASE_URL=http://192.168.100.26:6950/api ; BASE_URL=http://192.168.100.26:6950/api
; BASE_URL=http://10.69.5.204:6950/api ; BASE_URL=http://173.212.247.232:6950/api
; BASE_URL=http://192.168.1.164:6950/api ; BASE_URL=http://192.168.1.164:6950/api
; BASE_URL=http://192.168.120.160:6950/api ; BASE_URL=http://192.168.120.160:6950/api
; BASE_URL=http://10.10.2.92:6950/api ; BASE_URL=http://10.10.2.92:6950/api
@@ -8,3 +8,4 @@ BASE_URL=https://peakapi.qantra.co.zw/api
; BASE_URL=http://10.10.3.92:6950/api ; BASE_URL=http://10.10.3.92:6950/api
CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com
SIMULATE_PAYMENT_SUCCESS=false SIMULATE_PAYMENT_SUCCESS=false
; SIMULATE_PAYMENT_SUCCESS=true

BIN
assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
assets/velocity.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

5
devtools_options.yaml Normal file
View File

@@ -0,0 +1,5 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
- provider: true
- shared_preferences: true

View File

@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Peak</string> <string>Velocity</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -13,7 +13,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>Peak</string> <string>Velocity</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>

7
lib/interop.dart Normal file
View File

@@ -0,0 +1,7 @@
import 'dart:js_interop';
@JS('configure')
external void configure(String sessionID);
@JS('showEmbeddedPage')
external void showEmbeddedPage();

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:qpay/navbar.dart';
import 'package:qpay/screens/confirm/confirm_screen.dart'; import 'package:qpay/screens/confirm/confirm_screen.dart';
import 'package:qpay/screens/gateway/gateway_screen.dart'; import 'package:qpay/screens/gateway/gateway_screen.dart';
import 'package:qpay/screens/integration/integration_screen.dart'; import 'package:qpay/screens/integration/integration_screen.dart';
@@ -13,9 +14,11 @@ import 'package:qpay/screens/onboarding/phone/phone_screen.dart';
import 'package:qpay/screens/onboarding/register/register_screen.dart'; import 'package:qpay/screens/onboarding/register/register_screen.dart';
import 'package:qpay/screens/onboarding/verify/verify_screen.dart'; import 'package:qpay/screens/onboarding/verify/verify_screen.dart';
import 'package:qpay/screens/pay/pay_screen.dart'; import 'package:qpay/screens/pay/pay_screen.dart';
import 'package:qpay/screens/poll/poll_screen.dart';
import 'package:qpay/screens/receipt/receipt_screen.dart'; import 'package:qpay/screens/receipt/receipt_screen.dart';
import 'package:qpay/screens/recipient/recipients_screen.dart'; import 'package:qpay/screens/recipient/recipients_screen.dart';
import 'package:qpay/screens/transaction_controller.dart'; import 'package:qpay/screens/transaction_controller.dart';
import 'screens/gateway/gateway_web_screen.dart';
import 'screens/home/home_screen.dart'; import 'screens/home/home_screen.dart';
import 'screens/history/history_screen.dart'; import 'screens/history/history_screen.dart';
import 'screens/profile_screen.dart'; import 'screens/profile_screen.dart';
@@ -66,7 +69,7 @@ class _MyAppState extends State<MyApp> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_showSplash) { if (_showSplash) {
return MaterialApp( return MaterialApp(
title: 'Peak', title: 'Velocity',
theme: AppTheme.lightTheme, theme: AppTheme.lightTheme,
themeMode: ThemeMode.light, themeMode: ThemeMode.light,
home: SplashScreen(onSplashComplete: _onSplashComplete), home: SplashScreen(onSplashComplete: _onSplashComplete),
@@ -75,7 +78,7 @@ class _MyAppState extends State<MyApp> {
} }
return MaterialApp.router( return MaterialApp.router(
title: 'Peak', title: 'Velocity',
theme: AppTheme.lightTheme, theme: AppTheme.lightTheme,
themeMode: ThemeMode.light, // Force light mode regardless of OS setting themeMode: ThemeMode.light, // Force light mode regardless of OS setting
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
@@ -112,6 +115,14 @@ class _MyAppState extends State<MyApp> {
path: '/gateway', path: '/gateway',
builder: (context, state) => const GatewayScreen(), builder: (context, state) => const GatewayScreen(),
), ),
GoRoute(
path: '/gateway-web',
builder: (context, state) => const GatewayWebScreen(),
),
GoRoute(
path: '/poll',
builder: (context, state) => const PollScreen(),
),
GoRoute( GoRoute(
path: '/receipt', path: '/receipt',
builder: (context, state) => const ReceiptScreen(), builder: (context, state) => const ReceiptScreen(),
@@ -167,65 +178,3 @@ class _MyAppState extends State<MyApp> {
); );
} }
} }
class ScaffoldWithNavBar extends StatelessWidget {
const ScaffoldWithNavBar({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
onDestinationSelected: (index) {
switch (index) {
case 0:
context.go('/home');
break;
// case 1:
// context.go('/recipients');
// break;
case 1:
context.go('/history');
break;
// case 2:
// context.go('/profile');
// break;
}
},
selectedIndex: _calculateSelectedIndex(context),
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
),
// NavigationDestination(
// icon: Icon(Icons.contacts_outlined),
// selectedIcon: Icon(Icons.contacts),
// label: 'Recipients',
// ),
NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'History',
),
// NavigationDestination(
// icon: Icon(Icons.person_outline),
// selectedIcon: Icon(Icons.person),
// label: 'Profile',
// ),
],
),
);
}
int _calculateSelectedIndex(BuildContext context) {
final String location = GoRouterState.of(context).uri.path;
// if (location.startsWith('/recipients')) return 1;
if (location.startsWith('/history')) return 1;
// if (location.startsWith('/profile')) return 2;
return 0;
}
}

View File

@@ -0,0 +1,5 @@
class ResponsivePolicy {
static const int sm = 400;
static const int md = 600;
static const int lg = 950;
}

209
lib/navbar.dart Normal file
View File

@@ -0,0 +1,209 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'models/responsive_policy.dart';
class ScaffoldWithNavBar extends StatefulWidget {
const ScaffoldWithNavBar({super.key, required this.child});
final Widget child;
@override
State<ScaffoldWithNavBar> createState() => _ScaffoldWithNavBarState();
}
class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
bool _isLoggedIn = false;
late String initials;
@override
void initState() {
super.initState();
init();
}
Future<void> init() async {
var prefs = await SharedPreferences.getInstance();
if (prefs.getString("token") != null) {
setState(() {
_isLoggedIn = true;
initials = prefs.getString("initials")!;
});
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > ResponsivePolicy.md) {
return Scaffold(
body: SafeArea(
child: Row(
children: [
NavigationRail(
extended: true,
leading: Padding(
padding: const EdgeInsets.only(top: 20.0, bottom: 10),
child: Image(
image: AssetImage('assets/velocity.png'),
width: 150
),
),
trailing: _buildLogin(context),
labelType: NavigationRailLabelType.none,
indicatorColor: Theme.of(
context,
).colorScheme.primary.withAlpha(30),
onDestinationSelected: (index) {
_handleTap(index, context);
},
selectedIndex: _calculateSelectedIndex(context),
destinations: _buildNavigationRailDestinations(),
),
VerticalDivider(
thickness: 1,
width: 1,
color: Theme.of(context).colorScheme.primary.withAlpha(30),
),
Expanded(child: widget.child),
],
),
),
);
}
return Scaffold(
body: widget.child,
bottomNavigationBar: NavigationBar(
indicatorColor: Theme.of(context).colorScheme.primary.withAlpha(30),
onDestinationSelected: (index) {
_handleTap(index, context);
},
selectedIndex: _calculateSelectedIndex(context),
destinations: _buildNavigationDestinations(),
),
);
},
);
}
List<NavigationRailDestination> _buildNavigationRailDestinations() {
List<NavigationRailDestination> destinations = [];
for (var destination in _buildDestinationData()) {
destinations.add(
NavigationRailDestination(
icon: destination["icon"] as Widget,
selectedIcon: destination["selectedIcon"] as Widget,
label: Text(destination["label"], style: TextStyle(fontSize: 16)),
),
);
}
return destinations;
}
List<NavigationDestination> _buildNavigationDestinations() {
List<NavigationDestination> destinations = [];
for (var destination in _buildDestinationData()) {
destinations.add(
NavigationDestination(
icon: destination["icon"] as Widget,
selectedIcon: destination["selectedIcon"] as Widget,
label: destination["label"] as String,
),
);
}
return destinations;
}
List<Map<String, dynamic>> _buildDestinationData() {
return [
{
"icon": Icon(Icons.home_outlined),
"selectedIcon": Icon(Icons.home),
"label": 'Home',
},
{
"icon": Icon(Icons.history_outlined),
"selectedIcon": Icon(Icons.history),
"label": 'History',
},
];
}
Widget _buildLogin(BuildContext context) {
if(_isLoggedIn) {
return SizedBox();
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Column(
children: [
Container(
width: 220,
decoration: BoxDecoration(
border: Border.all(color: Colors.black87.withAlpha(20)),
borderRadius: BorderRadius.circular(12),
),
padding: EdgeInsets.symmetric(vertical: 10, horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 10,
children: [
Text(
'Register or Login for more features',
style: TextStyle(fontSize: 16),
),
InkWell(
onTap: () {
context.push('/onboarding/landing');
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text('Get Started', style: TextStyle(fontSize: 14)),
),
),
],
),
),
SizedBox(height: 10),
],
),
);
}
void _handleTap(int index, BuildContext context) {
switch (index) {
case 0:
context.go('/home');
break;
// case 1:
// context.go('/recipients');
// break;
case 1:
context.go('/history');
break;
// case 2:
// context.go('/profile');
// break;
}
}
int _calculateSelectedIndex(BuildContext context) {
final String location = GoRouterState.of(context).uri.path;
// if (location.startsWith('/recipients')) return 1;
if (location.startsWith('/history')) return 1;
// if (location.startsWith('/profile')) return 2;
return 0;
}
}

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -81,10 +82,13 @@ class ConfirmController extends ChangeNotifier {
transactionController.updateReceiptData(response); transactionController.updateReceiptData(response);
if (transactionController.model.selectedPaymentProcessor.authType == if (transactionController.model.selectedPaymentProcessor.authType == "WEB") {
"WEB") {
if (context.mounted) { if (context.mounted) {
context.push('/gateway'); if(kIsWeb){
context.push('/gateway-web');
}else {
context.push('/gateway');
}
} }
} else { } else {
await pollTransaction(transactionController.model.receiptData?['id']); await pollTransaction(transactionController.model.receiptData?['id']);

View File

@@ -1,5 +1,6 @@
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:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/confirm/confirm_controller.dart'; import 'package:qpay/screens/confirm/confirm_controller.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
@@ -88,255 +89,266 @@ class _ConfirmScreenState extends State<ConfirmScreen>
position: _slideAnimation, position: _slideAnimation,
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Center(
crossAxisAlignment: CrossAxisAlignment.start, child: LayoutBuilder(
children: [ builder: (context, constraints) {
Container( return SizedBox(
padding: EdgeInsets.all(15), width: double.parse(constraints.maxWidth > ResponsivePolicy.md ?
decoration: BoxDecoration( ResponsivePolicy.md.toString() :
border: Border.all( constraints.maxWidth.toString()),
color: Theme.of( child: Column(
context, crossAxisAlignment: CrossAxisAlignment.start,
).colorScheme.primary.withOpacity(0.3), children: [
), Container(
borderRadius: BorderRadius.circular(10), padding: EdgeInsets.all(15),
), decoration: BoxDecoration(
border: Border.all(
child: Column( color: Theme.of(
crossAxisAlignment: CrossAxisAlignment.start, context,
children: [ ).colorScheme.primary.withOpacity(0.3),
Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
width: 1.0,
color:
Theme.of(context).colorScheme.primary,
), ),
borderRadius: BorderRadius.circular(10),
), ),
),
padding: EdgeInsets.only( child: Column(
bottom: 4.0, crossAxisAlignment: CrossAxisAlignment.start,
), // Adjust spacing children: [
child: Text( Container(
"PROVIDER DETAILS", decoration: BoxDecoration(
style: TextStyle( border: Border(
fontSize: 16, bottom: BorderSide(
fontWeight: FontWeight.bold, width: 1.0,
), color:
), Theme.of(context).colorScheme.primary,
), ),
...controller ),
.transactionController ),
.model padding: EdgeInsets.only(
.confirmationData["additionalData"] bottom: 4.0,
.map<Widget>((data) { ), // Adjust spacing
return Column( child: Text(
children: [ "PROVIDER DETAILS",
const SizedBox(height: 10), style: TextStyle(
Row( fontSize: 16,
mainAxisAlignment: fontWeight: FontWeight.bold,
MainAxisAlignment.spaceBetween, ),
),
),
...controller
.transactionController
.model
.confirmationData["additionalData"]
.map<Widget>((data) {
return Column(
children: [
const SizedBox(height: 10),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
data["name"] ?? "",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
data["value"] ?? "",
style: TextStyle(fontSize: 16),
),
],
),
],
);
})
.toList(),
const SizedBox(height: 30),
Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
width: 1.0,
color:
Theme.of(context).colorScheme.primary,
),
),
),
padding: EdgeInsets.only(
bottom: 4.0,
), // Adjust spacing
child: Text(
"TRANSACTIONS DETAILS",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Amount",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
controller
.transactionController
.model
.confirmationData["amount"]
.toString(),
style: TextStyle(fontSize: 16),
),
],
),
const SizedBox(height: 10),
if (controller
.transactionController
.model
.confirmationData["charge"] !=
0)
Column(
children: [ children: [
Text( Row(
data["name"] ?? "", mainAxisAlignment:
style: TextStyle( MainAxisAlignment.spaceBetween,
fontSize: 16, children: [
fontWeight: FontWeight.bold, Text(
), "Our Charge",
), style: TextStyle(
Text( fontSize: 16,
data["value"] ?? "", fontWeight: FontWeight.bold,
style: TextStyle(fontSize: 16), ),
),
Text(
controller
.transactionController
.model
.confirmationData["charge"]
.toString(),
style: TextStyle(fontSize: 16),
),
],
), ),
const SizedBox(height: 10),
], ],
), ),
], if (controller
);
})
.toList(),
const SizedBox(height: 30),
Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
width: 1.0,
color:
Theme.of(context).colorScheme.primary,
),
),
),
padding: EdgeInsets.only(
bottom: 4.0,
), // Adjust spacing
child: Text(
"TRANSACTIONS DETAILS",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Amount",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
controller
.transactionController
.model
.confirmationData["amount"]
.toString(),
style: TextStyle(fontSize: 16),
),
],
),
const SizedBox(height: 10),
if (controller
.transactionController
.model
.confirmationData["charge"] !=
0)
Column(
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Our Charge",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
controller
.transactionController .transactionController
.model .model
.confirmationData["charge"] .confirmationData["gatewayCharge"] !=
.toString(), 0)
style: TextStyle(fontSize: 16), Column(
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Gateway Charge",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
controller
.transactionController
.model
.confirmationData["gatewayCharge"]
.toString(),
style: TextStyle(fontSize: 16),
),
],
),
const SizedBox(height: 10),
],
), ),
], if (controller
),
const SizedBox(height: 10),
],
),
if (controller
.transactionController
.model
.confirmationData["gatewayCharge"] !=
0)
Column(
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Gateway Charge",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
controller
.transactionController .transactionController
.model .model
.confirmationData["gatewayCharge"] .confirmationData["tax"] !=
.toString(), 0)
style: TextStyle(fontSize: 16), Column(
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Tax",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
controller
.transactionController
.model
.confirmationData["tax"]
.toString(),
style: TextStyle(fontSize: 16),
),
],
),
const SizedBox(height: 10),
],
), ),
], Row(
), mainAxisAlignment:
const SizedBox(height: 10), MainAxisAlignment.spaceBetween,
], children: [
), Text(
if (controller "Total Amount",
.transactionController style: TextStyle(
.model fontSize: 16,
.confirmationData["tax"] != fontWeight: FontWeight.bold,
0) ),
Column(
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Tax",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
), ),
Text(
controller
.transactionController
.model
.confirmationData["totalAmount"]
.toString(),
style: TextStyle(fontSize: 16),
),
],
),
const SizedBox(height: 5),
Text(
"NB: Your payment provider may charge additional fees.",
style: TextStyle(
fontSize: 11,
color: Colors.red,
), ),
Text( ),
controller ],
.transactionController
.model
.confirmationData["tax"]
.toString(),
style: TextStyle(fontSize: 16),
),
],
),
const SizedBox(height: 10),
],
),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Total Amount",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
), ),
Text(
controller
.transactionController
.model
.confirmationData["totalAmount"]
.toString(),
style: TextStyle(fontSize: 16),
),
],
),
const SizedBox(height: 5),
Text(
"NB: Your payment provider may charge additional fees.",
style: TextStyle(
fontSize: 11,
color: Colors.red,
), ),
), const SizedBox(height: 20),
], _buildPaymentProcessorButton(),
), const SizedBox(height: 20),
), Center(
const SizedBox(height: 20), child: OutlinedButton(
_buildPaymentProcessorButton(), child: Text('Cancel'),
const SizedBox(height: 20), onPressed: () {
Center( context.go('/');
child: OutlinedButton( },
child: Text('Cancel'), ),
onPressed: () { ),
context.go('/'); ],
}, ),
), );
), }
], ),
), ),
), ),
), ),

View File

@@ -76,15 +76,22 @@ class GatewayController extends ChangeNotifier {
} }
Future<void> pollTransaction(String uid) async { Future<void> pollTransaction(String uid) async {
// only poll for 5 minutes
int max = 30;
while (!model.isCancelled) { while (!model.isCancelled) {
max++;
// Check cancellation flag instead of true // Check cancellation flag instead of true
// Wait 5 seconds before the next poll // Wait 5 seconds before the next poll
await Future.delayed(const Duration(seconds: 5)); await Future.delayed(const Duration(seconds: 10));
// Check again after delay in case it was cancelled during the delay // Check again after delay in case it was cancelled during the delay
if (model.isCancelled) break; if (model.isCancelled) break;
await poll(uid); await poll(uid);
if(max > 30) {
model.isCancelled = true;
}
} }
} }

View File

@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -17,6 +15,7 @@ class _GatewayScreenState extends State<GatewayScreen> {
late WebViewController _webViewController; late WebViewController _webViewController;
late GatewayController controller; late GatewayController controller;
late String url; late String url;
String? sessionID;
bool integrationStarted = false; bool integrationStarted = false;
@@ -24,63 +23,71 @@ class _GatewayScreenState extends State<GatewayScreen> {
void initState() { void initState() {
super.initState(); super.initState();
controller = GatewayController(context); controller = GatewayController(context);
// https://na.gateway.mastercard.com/checkout/pay/SESSION0002220827459I8059951J88?checkoutVersion=1.0.0
url = controller.transactionController.model.receiptData?['targetUrl']; url = controller.transactionController.model.receiptData?['targetUrl'];
_initWebView(url);
_initWebView();
}
void _initWebView() async {
_webViewController = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onProgress: (int progress) {
// Update loading bar.
},
onPageStarted: (String url) {
controller.startLoading();
},
onPageFinished: (String url) {
controller.finishLoading();
},
onHttpError: (HttpResponseError error) {},
onWebResourceError: (WebResourceError error) {},
onUrlChange: (UrlChange url) async {
print("URL Changed to ${url.url!}");
_navigateWhenDone(url.url!);
},
),
)
..loadRequest(Uri.parse(url));
bool simulate = bool.parse(dotenv.env['SIMULATE_PAYMENT_SUCCESS']!); bool simulate = bool.parse(dotenv.env['SIMULATE_PAYMENT_SUCCESS']!);
if(simulate) { if (simulate) {
sleep(Duration(seconds: 10)); await Future.delayed(Duration(seconds: 10));
String targetUrl = url.replaceAll("/pay/", "/receipt/"); String targetUrl = url.replaceAll("/pay/", "/receipt/");
_webViewController.loadRequest(Uri.parse(targetUrl)); _webViewController.loadRequest(Uri.parse(targetUrl));
} }
} }
void _initWebView(String url) { void _navigateWhenDone(String url) {
_webViewController = if (url.contains("/checkout/receipt/")) {
WebViewController() if (integrationStarted) {
..setJavaScriptMode(JavaScriptMode.unrestricted) return;
..setNavigationDelegate( }
NavigationDelegate(
onProgress: (int progress) {
// Update loading bar.
},
onPageStarted: (String url) {
controller.startLoading();
},
onPageFinished: (String url) {
controller.finishLoading();
},
onHttpError: (HttpResponseError error) {},
onWebResourceError: (WebResourceError error) {},
onUrlChange: (UrlChange url) async {
print("URL Changed to ${url.url!}");
if(url.url!.contains("/checkout/receipt/")){ ScaffoldMessenger.of(context).showSnackBar(
if(integrationStarted) { SnackBar(
return; content: Text(
} "Payment was successful, please wait while we process your order.",
),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.black87,
duration: const Duration(seconds: 10),
),
);
ScaffoldMessenger.of(context).showSnackBar( setState(() {
SnackBar( integrationStarted = true;
content: Text("Payment was successful, please wait while we process your order."), });
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.black87,
duration: const Duration(seconds: 10),
),
);
setState(() { WidgetsBinding.instance.addPostFrameCallback((_) {
integrationStarted = true; context.go('/integration');
}); });
}
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/integration');
});
}
}
),
)
..loadRequest(Uri.parse(url));
} }
@override @override
@@ -94,15 +101,14 @@ class _GatewayScreenState extends State<GatewayScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Complete your payment'), title: const Text('Complete your payment'),
leading: leading: context.canPop()
context.canPop() ? IconButton(
? IconButton( onPressed: () {
onPressed: () { context.pop();
context.pop(); },
}, icon: const Icon(Icons.arrow_back),
icon: const Icon(Icons.arrow_back), )
) : SizedBox.shrink(),
: SizedBox.shrink(),
), ),
body: ListenableBuilder( body: ListenableBuilder(
listenable: controller, listenable: controller,
@@ -112,9 +118,12 @@ class _GatewayScreenState extends State<GatewayScreen> {
context.go('/integration'); context.go('/integration');
}); });
} }
return controller.model.isLoading return controller.model.isLoading
? const Center(child: CircularProgressIndicator(strokeWidth: 5)) ? const Center(child: CircularProgressIndicator(strokeWidth: 5))
: WebViewWidget(controller: _webViewController); : SizedBox.expand(
child: WebViewWidget(controller: _webViewController),
);
}, },
), ),
); );

View File

@@ -0,0 +1,259 @@
import 'dart:js_interop';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:go_router/go_router.dart';
import 'package:qpay/interop.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/gateway/gateway_controller.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:web/web.dart' as web;
class GatewayWebScreen extends StatefulWidget {
const GatewayWebScreen({super.key});
@override
State<GatewayWebScreen> createState() => _GatewayWebScreenState();
}
class _GatewayWebScreenState extends State<GatewayWebScreen> {
late WebViewController _webViewController;
late GatewayController controller;
late String url;
String? sessionID;
bool integrationStarted = false;
@override
void initState() {
super.initState();
controller = GatewayController(context);
url = controller.transactionController.model.receiptData?['targetUrl'];
}
// A function to set up the ResizeObserver
void setupAttachmentObserver(web.Element element) {
final observer = web.ResizeObserver(
(JSArray entries, JSAny observer) {
// The element has been laid out (and thus attached to the DOM)
onElementAttached(element);
// Disconnect the observer once the action is done
(observer as web.ResizeObserver).disconnect();
}.toJS,
);
observer.observe(element);
}
// Called after `element` is attached to the DOM.
void onElementAttached(web.Element element) {
// Your code to execute after the element is in the DOM goes here.
print('Element with ID ${element.id} is attached to the DOM.');
// You can now safely query the DOM or call JavaScript functions that rely
// on the element being present.
Future.delayed(const Duration(milliseconds: 50), () {
initIframeView();
});
}
void initIframeView() async {
final uri = Uri.parse(url);
// 2. The pathSegments property returns a list of the parts of the path.
// For "checkout/pay/SESSION...", the segments are ["checkout", "pay", "SESSION..."].
final pathSegments = uri.pathSegments;
// 3. Check if there are any segments and return the last one,
// which is the session ID in this URL structure.
if (pathSegments.isNotEmpty) {
sessionID = pathSegments.last;
configure(sessionID!);
}
showEmbeddedPage();
Future.delayed(const Duration(seconds: 10), () async {
web.HTMLIFrameElement element = fetchIframeFromDom(
"hc-comms-layer-iframe",
)!;
print(element);
element.onload = (JSAny event) {
// This is called every time a new page loads in the iframe
print(event);
try {
final newLocation = element.contentWindow?.location.href;
print('Iframe navigated to: $newLocation');
// Perform actions based on the new URL
_navigateWhenDone(newLocation!);
} catch (e) {
// Handle potential (though unlikely for same-origin) security errors
print('Could not access iframe location: $e');
}
}.toJS;
bool simulate = bool.parse(dotenv.env['SIMULATE_PAYMENT_SUCCESS']!);
if (simulate) {
await Future.delayed(Duration(milliseconds: 100));
_navigateWhenDone("/checkout/receipt/");
}
});
}
web.HTMLIFrameElement? fetchIframeFromDom(String id) {
final element = web.window.document.querySelector('#$id');
if (element is web.HTMLIFrameElement) {
print('Found iframe in DOM: ${element.id}');
print(element.toString());
return element;
}
print('Could not find iframe with id: $id in the DOM.');
return null;
}
void _navigateWhenDone(String url) {
if (url.contains("/checkout/receipt/")) {
if (integrationStarted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"Payment was successful, please wait while we process your order.",
),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.black87,
duration: const Duration(seconds: 10),
),
);
setState(() {
integrationStarted = true;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/integration');
});
}
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Complete your payment'),
leading: context.canPop()
? IconButton(
onPressed: () {
context.pop();
},
icon: const Icon(Icons.arrow_back),
)
: SizedBox.shrink(),
),
body: ListenableBuilder(
listenable: controller,
builder: (context, child) {
if (controller.model.status == 'SUCCESS') {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/integration');
});
}
return LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: Center(
child: SizedBox(
width: ResponsivePolicy.md.toDouble(),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
decoration: BoxDecoration(
border: Border.all(
color: Colors.black87.withAlpha(20),
),
borderRadius: BorderRadius.circular(12),
),
padding: EdgeInsets.symmetric(
vertical: 10,
horizontal: 20,
),
child: Row(
crossAxisAlignment:
CrossAxisAlignment.center,
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
'Once done on this page, check transaction status',
style: TextStyle(fontSize: 16),
),
InkWell(
onTap: () {
context.push('/poll');
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(
12,
),
),
child: Text(
'Check status',
style: TextStyle(fontSize: 12),
),
),
),
],
),
),
SizedBox(height: 10,),
SizedBox(
width: double.infinity,
height: constraints.maxHeight + 200,
child: HtmlElementView.fromTagName(
tagName: 'div',
onElementCreated: (element) {
final divElement =
element as web.HTMLDivElement;
divElement.id = "embed-target";
setupAttachmentObserver(element);
},
),
),
],
),
),
),
),
);
},
);
},
),
);
}
}

View File

@@ -92,7 +92,7 @@ class HistoryController extends ChangeNotifier {
"debitPhone": "", "debitPhone": "",
"debitAccount": "", "debitAccount": "",
"debitCurrency": "USD", "debitCurrency": "USD",
"debitRef": "peak", "debitRef": "Velocity",
"creditPhone": "", "creditPhone": "",
"creditAccount": "07088597534", "creditAccount": "07088597534",
"billClientId": "powertel_zesa", "billClientId": "powertel_zesa",

View File

@@ -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:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/transaction_controller.dart'; import 'package:qpay/screens/transaction_controller.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -23,7 +24,6 @@ class _HistoryScreenState extends State<HistoryScreen>
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final Map<String, String> _queryParams = {}; final Map<String, String> _queryParams = {};
final List<Animation<Offset>> _alignListAnimations = [];
final int _pageSize = 8; final int _pageSize = 8;
@override @override
@@ -38,21 +38,9 @@ class _HistoryScreenState extends State<HistoryScreen>
); );
_controller = AnimationController( _controller = AnimationController(
duration: const Duration(milliseconds: 600), duration: const Duration(milliseconds: 3000),
vsync: this, vsync: this,
)..forward(); )..forward();
List tranListIndices = [0, 1];
for (int i = 0; i < tranListIndices.length; i++) {
_alignListAnimations.add(
Tween<Offset>(begin: Offset(0, -0.25), end: Offset.zero).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0.075 * i, 1.0, curve: Curves.easeOut),
),
),
);
}
} }
void setupData() async { void setupData() async {
@@ -90,56 +78,78 @@ class _HistoryScreenState extends State<HistoryScreen>
builder: (context, child) { builder: (context, child) {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Center(
children: [ child: LayoutBuilder(
_buildSearchField(controller), builder: (context, constraints) {
if (controller.model.transactions.isEmpty) return SizedBox(
Column( width: double.parse(
children: [ constraints.maxWidth > ResponsivePolicy.md
SizedBox(height: 30), ? ResponsivePolicy.md.toString()
Text( : constraints.maxWidth.toString(),
'No transactions yet. Make a payment to see your recent activity.', ),
style: TextStyle(fontSize: 16), child: Column(
textAlign: TextAlign.center, children: [
), _buildSearchField(controller),
], SizedBox(height: 10),
), if (controller.model.transactions.isEmpty)
if (controller.model.transactions.isNotEmpty) Column(
Column( children: [
children: [ SizedBox(height: 30),
ListView( Text(
shrinkWrap: true, 'No transactions yet. Make a payment to see your recent activity.',
physics: const NeverScrollableScrollPhysics(), style: TextStyle(fontSize: 16),
children: [ textAlign: TextAlign.center,
...controller.model.transactions.map( ),
(e) => _buildTransactionItem(e, context), ],
), ),
], if (controller.model.transactions.isNotEmpty)
), Column(
SizedBox(height: 10), children: [
if (controller.model.pageableModel != null && ListView(
controller.model.pageableModel!.number < shrinkWrap: true,
controller.model.pageableModel!.totalPages) physics:
OutlinedButton( const NeverScrollableScrollPhysics(),
child: Text('Load more'), children: [
onPressed: () { ...controller.model.transactions.map(
controller.getTransactions({ (e) =>
'userId': prefs.getString("userId")!, _buildTransactionItem(e, context),
'page': ),
(controller ],
.model ),
.pageableModel! SizedBox(height: 10),
.number + if (controller.model.pageableModel !=
1) null &&
.toString(), controller.model.pageableModel!.number <
'size': _pageSize.toString(), controller
'sort': 'createdAt,desc', .model
}); .pageableModel!
}, .totalPages)
), OutlinedButton(
], child: Text('Load more'),
), onPressed: () {
], controller.getTransactions({
'userId': prefs.getString(
"userId",
)!,
'page':
(controller
.model
.pageableModel!
.number +
1)
.toString(),
'size': _pageSize.toString(),
'sort': 'createdAt,desc',
});
},
),
],
),
],
),
);
},
),
), ),
); );
}, },
@@ -191,20 +201,19 @@ class _HistoryScreenState extends State<HistoryScreen>
).colorScheme.primary.withOpacity(0.2), ).colorScheme.primary.withOpacity(0.2),
), ),
), ),
suffixIcon: suffixIcon: _searchController.text.isNotEmpty
_searchController.text.isNotEmpty ? IconButton(
? IconButton( onPressed: () {
onPressed: () { _searchController.clear();
_searchController.clear(); _queryParams.clear();
_queryParams.clear(); controller.getTransactions({
controller.getTransactions({ 'userId': prefs.getString("userId")!,
'userId': prefs.getString("userId")!, });
}); },
}, icon: Icon(Icons.clear),
icon: Icon(Icons.clear), color: Theme.of(context).colorScheme.primary,
color: Theme.of(context).colorScheme.primary, )
) : null,
: null,
), ),
onChanged: (value) { onChanged: (value) {
// controller.updateCreditAccount(value); // controller.updateCreditAccount(value);
@@ -227,8 +236,17 @@ class _HistoryScreenState extends State<HistoryScreen>
} }
Widget _buildTransactionItem(Map<String, dynamic> e, BuildContext context) { Widget _buildTransactionItem(Map<String, dynamic> e, BuildContext context) {
int index = controller.model.transactions.indexOf(e);
Animation<Offset> animation =
Tween<Offset>(begin: Offset(0, -0.25), end: Offset.zero).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0.175 * index, 1.0, curve: Curves.easeIn),
),
);
return SlideTransition( return SlideTransition(
position: _alignListAnimations[0], position: animation,
child: Skeletonizer( child: Skeletonizer(
enabled: controller.model.isLoading, enabled: controller.model.isLoading,
child: InkWell( child: InkWell(
@@ -260,18 +278,17 @@ class _HistoryScreenState extends State<HistoryScreen>
), ),
SizedBox(width: 10), SizedBox(width: 10),
Icon( Icon(
e['status'] == 'SUCCESS' e['integrationStatus'] == 'SUCCESS'
? Icons.check_circle ? Icons.check_circle
: e['status'] == 'PENDING' : e['integrationStatus'] == 'PENDING'
? Icons.pending ? Icons.pending
: Icons.cancel, : Icons.cancel,
size: 16, size: 16,
color: color: e['integrationStatus'] == 'SUCCESS'
e['status'] == 'SUCCESS' ? Colors.green
? Colors.green : e['integrationStatus'] == 'PENDING'
: e['status'] == 'PENDING' ? Colors.orange
? Colors.orange : Colors.red,
: Colors.red,
), ),
], ],
), ),

View File

@@ -104,6 +104,9 @@ class HomeController extends ChangeNotifier {
Future<void> getProviders() async { Future<void> getProviders() async {
try { try {
model.filterableProviders = getFakeProviders();
model.transactions = getFakeTransactions();
model.isLoading = true; model.isLoading = true;
notifyListeners(); notifyListeners();
@@ -138,6 +141,7 @@ class HomeController extends ChangeNotifier {
Future<void> getTransactions(String userId) async { Future<void> getTransactions(String userId) async {
model.isLoading = true; model.isLoading = true;
// model.transactions = getFakeTransactions();
notifyListeners(); notifyListeners();
try { try {
@@ -147,8 +151,9 @@ class HomeController extends ChangeNotifier {
PageableModel pageableModel = PageableModel.fromJson(response); PageableModel pageableModel = PageableModel.fromJson(response);
model.transactions = model.transactions = pageableModel.content
pageableModel.content.map((e) => e as Map<String, dynamic>).toList(); .map((e) => e as Map<String, dynamic>)
.toList();
} catch (e) { } catch (e) {
logger.e(e); logger.e(e);
_showErrorSnackBar( _showErrorSnackBar(
@@ -165,8 +170,6 @@ class HomeController extends ChangeNotifier {
Future<void> getCategories() async { Future<void> getCategories() async {
// activate skeletonizer // activate skeletonizer
model.categories = getFakeCategoryData(); model.categories = getFakeCategoryData();
model.transactions = getFakeTransactions();
// model.filterableProviders = getFakeProviders();
model.isLoading = true; model.isLoading = true;
notifyListeners(); notifyListeners();
@@ -200,10 +203,9 @@ class HomeController extends ChangeNotifier {
model.selectedCategory = category; model.selectedCategory = category;
model.filterableProviders = model.filterableProviders = model.providers
model.providers .where((element) => element.category == category.label)
.where((element) => element.category == category.label) .toList();
.toList();
notifyListeners(); notifyListeners();
} }
@@ -224,7 +226,7 @@ class HomeController extends ChangeNotifier {
"debitPhone": "", "debitPhone": "",
"debitAccount": "", "debitAccount": "",
"debitCurrency": "USD", "debitCurrency": "USD",
"debitRef": "peak", "debitRef": "Velocity",
"creditPhone": "", "creditPhone": "",
"creditAccount": "07088597534", "creditAccount": "07088597534",
"billClientId": "powertel_zesa", "billClientId": "powertel_zesa",
@@ -242,21 +244,21 @@ class HomeController extends ChangeNotifier {
List<BillProvider> getFakeProviders() { List<BillProvider> getFakeProviders() {
return [ return [
BillProvider( BillProvider(
clientId: '1', clientId: 'econet_airtime',
name: 'ZESA', name: 'Econet Airtime',
description: 'Pay your electricity bills easily', description: 'Econet Airtime',
requiresAccount: false, requiresAccount: false,
requiresAmount: false, requiresAmount: true,
requiresAmountFromMerchant: false, requiresAmountFromMerchant: false,
requiresPhone: false, requiresPhone: true,
requiresReversal: false, requiresReversal: true,
additionalDataString: '', additionalDataString: '',
processorType: '', processorType: 'AIRTIME',
uid: '', uid: '78f1f497-c9eb-401c-b22c-884756e68e40',
image: 'econet.png', image: 'econet.png',
label: '', label: 'ECONET',
category: '', category: 'ECONET',
accountFieldName: '', accountFieldName: 'Phone Number',
), ),
]; ];
} }

File diff suppressed because it is too large Load Diff

View File

@@ -63,12 +63,14 @@ class IntegrationController extends ChangeNotifier {
model.status = response['status']; model.status = response['status'];
transactionController.updateReceiptData(response); transactionController.updateReceiptData(response);
gatewayController.poll(transactionController.model.confirmationData['id']); await gatewayController.poll(transactionController.model.confirmationData['id']);
} else {
model.status = response['status'];
model.errorMessage = response['errorMessage'];
_showErrorSnackBar(response['errorMessage']);
} }
// regardless of poll result we proceed to receipt
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/receipt');
});
model.isLoading = false; model.isLoading = false;
notifyListeners(); notifyListeners();

View File

@@ -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:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/transaction_controller.dart'; import 'package:qpay/screens/transaction_controller.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
@@ -68,49 +69,55 @@ class _IntegrationScreenState extends State<IntegrationScreen> {
return Container( return Container(
padding: EdgeInsets.all(50), padding: EdgeInsets.all(50),
child: Center( child: Center(
child: Column( child: LayoutBuilder(
mainAxisAlignment: MainAxisAlignment.center, builder: (context, constraints) {
crossAxisAlignment: CrossAxisAlignment.center, return SizedBox(
children: [ width: double.parse(constraints.maxWidth > ResponsivePolicy.md ?
SizedBox(height: 75), ResponsivePolicy.md.toString() :
constraints.maxWidth.toString()),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 75),
Column( Column(
children: [ children: [
Text( Text(
'We\'ve successfully collected your funds. We are now ' 'We\'ve successfully collected your funds. We are now '
'updating your billing account. This won\'t take long', 'updating your billing account. This won\'t take long',
style: TextStyle(fontSize: 20), style: TextStyle(fontSize: 20),
textAlign: TextAlign.center, textAlign: TextAlign.center,
),
Image.asset('assets/peak-animation.gif', width: 150),
SizedBox(
width: 50,
child: LinearProgressIndicator(),
),
SizedBox(height: 50),
if(controller.model.status == 'FAILED')
Column(
children: [
Text(
'We failed to update your billing account',
style: TextStyle(fontSize: 20),
textAlign: TextAlign.center,
),
SizedBox(height: 20,),
Skeletonizer(
enabled: controller.model.isLoading,
child: OutlinedButton(
child: Text('Retry'),
onPressed: () {
controller.doIntegration();
},
), ),
), SizedBox(height: 50,),
], Image.asset('assets/velocity-animation.gif', width: 150),
), SizedBox(height: 50),
], if(controller.model.status == 'FAILED')
), Column(
], children: [
Text(
'We failed to update your billing account',
style: TextStyle(fontSize: 20),
textAlign: TextAlign.center,
),
SizedBox(height: 20,),
Skeletonizer(
enabled: controller.model.isLoading,
child: OutlinedButton(
child: Text('Retry'),
onPressed: () {
controller.doIntegration();
},
),
),
],
),
],
),
],
),
);
}
), ),
), ),
); );

View File

@@ -1,5 +1,6 @@
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:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/onboarding/login/login_controller.dart'; import 'package:qpay/screens/onboarding/login/login_controller.dart';
class CompleteScreen extends StatefulWidget { class CompleteScreen extends StatefulWidget {
@@ -16,56 +17,61 @@ class _CompleteScreenState extends State<CompleteScreen> {
body: SingleChildScrollView( body: SingleChildScrollView(
child: Padding( child: Padding(
padding: const EdgeInsets.all(25), padding: const EdgeInsets.all(25),
child: Column( child: Center(
mainAxisAlignment: MainAxisAlignment.center, child: LayoutBuilder(
crossAxisAlignment: CrossAxisAlignment.center, builder: (context, constraints) {
children: [ final maxWidth = constraints.maxWidth > ResponsivePolicy.md
SizedBox(height: 75), ? ResponsivePolicy.md.toDouble()
Text( : constraints.maxWidth;
'Registration Complete', return SizedBox(
style: TextStyle( width: maxWidth,
fontSize: 30, child: Column(
fontWeight: FontWeight.bold, mainAxisAlignment: MainAxisAlignment.center,
color: Theme.of(context).colorScheme.primary, crossAxisAlignment: CrossAxisAlignment.center,
), children: [
), SizedBox(height: 75),
SizedBox(height: 5), Text(
Text( 'Registration Complete',
'Thank you for registering with Peak payments', style: TextStyle(
style: TextStyle(fontSize: 18), fontSize: 30,
textAlign: TextAlign.center, fontWeight: FontWeight.bold,
), color: Theme.of(context).colorScheme.primary,
Image.asset('assets/landing.png', width: 300), ),
Text( ),
'Tap Login to get started', SizedBox(height: 5),
style: TextStyle(fontSize: 18), Text(
), 'Thank you for registering with Velocity payments',
SizedBox(height: 10), style: TextStyle(fontSize: 18),
], textAlign: TextAlign.center,
), ),
), Image.asset('assets/landing.png', width: 300),
), Text(
bottomNavigationBar: SizedBox( 'Tap Login to get started',
height: 130, style: TextStyle(fontSize: 18),
child: Column( ),
children: [ SizedBox(height: 30),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 25), padding: const EdgeInsets.symmetric(horizontal: 25),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () {
context.go('/onboarding/login'); context.go('/onboarding/login');
}, },
child: Text('Go to Login', style: TextStyle(fontSize: 16)), child: Text('Go to Login', style: TextStyle(fontSize: 16)),
), ),
),
],
),
),
SizedBox(height: 5),
],
), ),
], );
), }
), ),
SizedBox(height: 5), ),
],
), ),
), ),
); );

View File

@@ -1,5 +1,6 @@
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:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/onboarding/login/login_controller.dart'; import 'package:qpay/screens/onboarding/login/login_controller.dart';
class LandingScreen extends StatefulWidget { class LandingScreen extends StatefulWidget {
@@ -16,73 +17,82 @@ class _LandingScreenState extends State<LandingScreen> {
body: SingleChildScrollView( body: SingleChildScrollView(
child: Padding( child: Padding(
padding: const EdgeInsets.all(25), padding: const EdgeInsets.all(25),
child: Column( child: Center(
mainAxisAlignment: MainAxisAlignment.center, child: LayoutBuilder(
crossAxisAlignment: CrossAxisAlignment.center, builder: (context, constraints) {
children: [ final maxWidth = constraints.maxWidth > ResponsivePolicy.md
SizedBox(height: 75), ? ResponsivePolicy.md.toDouble()
Text( : constraints.maxWidth;
'Welcome to Peak', return SizedBox(
style: TextStyle( width: maxWidth,
fontSize: 30, child: Column(
fontWeight: FontWeight.bold, mainAxisAlignment: MainAxisAlignment.center,
color: Theme.of(context).colorScheme.primary, crossAxisAlignment: CrossAxisAlignment.center,
), children: [
), SizedBox(height: 75),
SizedBox(height: 5), Text(
Text( 'Welcome to Velocity',
'The fastest way to pay for local goods and services', style: TextStyle(
style: TextStyle(fontSize: 18), fontSize: 30,
textAlign: TextAlign.center, fontWeight: FontWeight.bold,
), color: Theme.of(context).colorScheme.primary,
Image.asset('assets/landing.png', width: 300), ),
Image.asset('assets/peak.png', width: 100), ),
SizedBox(height: 5), SizedBox(height: 5),
Text( Text(
'Register or login to get started', 'The fastest way to pay for local goods and services',
style: TextStyle(fontSize: 16), style: TextStyle(fontSize: 18),
), textAlign: TextAlign.center,
SizedBox(height: 10), ),
], Image.asset('assets/landing.png', width: 300),
), Image.asset('assets/velocity.png', width: 150),
), SizedBox(height: 5),
), Text(
bottomNavigationBar: SizedBox( 'Register or login to get started',
height: 130, style: TextStyle(fontSize: 16),
child: Column( ),
children: [ SizedBox(height: 50),
Padding( Column(
padding: const EdgeInsets.symmetric(horizontal: 25), children: [
child: Row( Padding(
children: [ padding: const EdgeInsets.symmetric(horizontal: 25),
Expanded( child: Row(
child: ElevatedButton( children: [
onPressed: () { Expanded(
context.go('/onboarding/register'); child: ElevatedButton(
}, onPressed: () {
child: Text('Register', style: TextStyle(fontSize: 16)), context.go('/onboarding/register');
), },
child: Text('Register', style: TextStyle(fontSize: 16)),
),
),
SizedBox(width: 16),
Expanded(
child: OutlinedButton(
onPressed: () {
context.go('/onboarding/login');
},
child: Text('Login', style: TextStyle(fontSize: 16)),
),
),
],
),
),
SizedBox(height: 10),
TextButton(
onPressed: () {
context.go('/');
},
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
),
],
)
],
), ),
SizedBox(width: 16), );
Expanded(
child: TextButton(
onPressed: () {
context.go('/onboarding/login');
},
child: Text('Login', style: TextStyle(fontSize: 16)),
),
),
],
),
),
SizedBox(height: 5),
TextButton(
onPressed: () {
context.go('/');
}, },
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
), ),
], ),
), ),
), ),
); );

View File

@@ -1,12 +1,16 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:google_sign_in/google_sign_in.dart'; import 'package:google_sign_in/google_sign_in.dart';
import 'package:google_sign_in_web/web_only.dart' as web;
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/onboarding/login/login_controller.dart'; import 'package:qpay/screens/onboarding/login/login_controller.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:skeletonizer/skeletonizer.dart';
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
const LoginScreen({super.key}); const LoginScreen({super.key});
@@ -17,6 +21,8 @@ class LoginScreen extends StatefulWidget {
class _LoginScreenState extends State<LoginScreen> { class _LoginScreenState extends State<LoginScreen> {
late SharedPreferences prefs; late SharedPreferences prefs;
late GoogleSignIn googleSignIn;
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
bool _loading = false; bool _loading = false;
@@ -30,22 +36,23 @@ class _LoginScreenState extends State<LoginScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_loginController = LoginController(context); _loginController = LoginController(context);
googleSignIn = GoogleSignIn.instance;
if(kIsWeb){
googleSignIn.initialize();
googleSignIn.authenticationEvents
.listen(_handleAuthenticationEvent)
.onError(_handleAuthenticationError);
}else {
unawaited(googleSignIn.initialize(serverClientId: dotenv.env['CLIENTID']));
}
} }
Future<void> signInWithGoogle() async { Future<void> signInWithGoogle() async {
final GoogleSignIn googleSignIn = GoogleSignIn.instance;
unawaited(googleSignIn.initialize(serverClientId: dotenv.env['CLIENTID']));
if (GoogleSignIn.instance.supportsAuthenticate()){ if (GoogleSignIn.instance.supportsAuthenticate()){
unawaited(GoogleSignIn.instance.authenticate().then((value) async { unawaited(GoogleSignIn.instance.authenticate().then((value) async {
print(value);
saveUser(value);
await _loginController.login(value.email, value.id);
context.go('/');
googleSignIn.authenticationEvents googleSignIn.authenticationEvents
.listen(_handleAuthenticationEvent) .listen(_handleAuthenticationEvent)
.onError(_handleAuthenticationError); .onError(_handleAuthenticationError);
@@ -85,6 +92,12 @@ class _LoginScreenState extends State<LoginScreen> {
?.authorizationClient ?.authorizationClient
.authorizationForScopes(scopes); .authorizationForScopes(scopes);
// #enddocregion CheckAuthorization // #enddocregion CheckAuthorization
print(user);
saveUser(user as GoogleSignInAccount);
await _loginController.login(user.email, user.id);
context.go('/');
} }
Future<void> _handleAuthenticationError(Object e) async { Future<void> _handleAuthenticationError(Object e) async {
@@ -105,100 +118,117 @@ class _LoginScreenState extends State<LoginScreen> {
padding: const EdgeInsets.all(25), padding: const EdgeInsets.all(25),
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Center(
mainAxisAlignment: MainAxisAlignment.center, child: LayoutBuilder(
crossAxisAlignment: CrossAxisAlignment.center, builder: (context, constraints) {
children: [ final maxWidth = constraints.maxWidth > ResponsivePolicy.md
SizedBox(height: 75), ? ResponsivePolicy.md.toDouble()
Text( : constraints.maxWidth;
'Login', return SizedBox(
style: TextStyle( width: maxWidth,
fontSize: 30, child: Column(
fontWeight: FontWeight.bold, mainAxisAlignment: MainAxisAlignment.center,
color: Theme.of(context).colorScheme.primary, crossAxisAlignment: CrossAxisAlignment.center,
), children: [
), SizedBox(height: 75),
SizedBox(height: 5), Text(
Text( 'Login',
'Welcome back to Peak', style: TextStyle(
style: TextStyle(fontSize: 18), fontSize: 30,
textAlign: TextAlign.center, fontWeight: FontWeight.bold,
), color: Theme.of(context).colorScheme.primary,
SizedBox(height: 40),
Image.asset('assets/password.png', width: 300),
Text(
'Tap on your preferred social media platform to get started',
style: TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
// Username Field
SizedBox(height: 20),
// Forgot Password Link
// Divider with "or" text
Row(
children: [
Expanded(child: Divider(color: Colors.grey.shade300)),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text(
'choose from the options below to continue',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 14,
),
textAlign: TextAlign.center,
),
),
Expanded(child: Divider(color: Colors.grey.shade300)),
],
),
SizedBox(height: 30),
// Social Login Buttons
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () async {
await signInWithGoogle();
},
icon: Image.asset(
'assets/google.png',
width: 20,
height: 20,
),
label: Text('Google', style: TextStyle(fontSize: 16)),
style: OutlinedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 16),
side: BorderSide(color: Colors.grey.shade300),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
), ),
), ),
), SizedBox(height: 5),
Text(
'Welcome back to Velocity',
style: TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
SizedBox(height: 30),
Image.asset('assets/password.png', width: 300),
Text(
'Tap on your preferred social media platform to get started',
style: TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
// Username Field
SizedBox(height: 20),
// Forgot Password Link
// Divider with "or" text
Row(
children: [
Expanded(child: Divider(color: Colors.grey.shade300)),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text(
'choose from the options below to continue',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 14,
),
textAlign: TextAlign.center,
),
),
Expanded(child: Divider(color: Colors.grey.shade300)),
],
),
SizedBox(height: 30),
// Social Login Buttons
if(GoogleSignIn.instance.supportsAuthenticate())
_buildGoogleButton()
else ...<Widget>[
if (kIsWeb)
web.renderButton()
],
SizedBox(height: 40),
TextButton(
onPressed: () {
context.go('/');
},
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
),
],
), ),
], );
), }
SizedBox(height: 40), ),
],
), ),
), ),
), ),
), ),
bottomNavigationBar: SizedBox( );
height: 130, }
child: Column(
children: [ Widget _buildGoogleButton(){
SizedBox(height: 5), return Skeletonizer(
TextButton( enabled: _loading,
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () { onPressed: () {
context.go('/'); signInWithGoogle();
}, },
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)), icon: Image.asset(
'assets/google.png',
width: 20,
height: 20,
),
label: Text('Google', style: TextStyle(fontSize: 16)),
style: OutlinedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 16),
side: BorderSide(color: Colors.grey.shade300),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
), ),
], ),
), SizedBox(width: 16),
],
), ),
); );
} }
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/onboarding/phone/phone_controller.dart'; import 'package:qpay/screens/onboarding/phone/phone_controller.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
@@ -82,89 +83,94 @@ class _PhoneScreenState extends State<PhoneScreen> {
padding: const EdgeInsets.all(25.0), padding: const EdgeInsets.all(25.0),
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Center(
mainAxisAlignment: MainAxisAlignment.center, child: LayoutBuilder(
crossAxisAlignment: CrossAxisAlignment.center, builder: (context, constraints) {
children: <Widget>[ final maxWidth = constraints.maxWidth > ResponsivePolicy.md
SizedBox(height: 75), ? ResponsivePolicy.md.toDouble()
Text( : constraints.maxWidth;
'Verify Your Device', return SizedBox(
style: TextStyle( width: maxWidth,
fontSize: 30, child: Column(
fontWeight: FontWeight.bold, mainAxisAlignment: MainAxisAlignment.center,
color: Theme crossAxisAlignment: CrossAxisAlignment.center,
.of(context) children: <Widget>[
.colorScheme SizedBox(height: 75),
.primary, Text(
), 'Verify Your Device',
textAlign: TextAlign.center, style: TextStyle(
), fontSize: 30,
SizedBox(height: 10), fontWeight: FontWeight.bold,
Text( color: Theme
'We will send you a verification code to this number', .of(context)
style: TextStyle(fontSize: 18), .colorScheme
textAlign: TextAlign.center, .primary,
), ),
Image.asset('assets/phone.png', width: 300), textAlign: TextAlign.center,
SizedBox(height: 20),
_buildPhoneField(),
SizedBox(height: 30),
],
),
),
),
),
bottomNavigationBar: SizedBox(
height: 130,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 25),
child: Skeletonizer(
enabled: phoneController.model.isLoading,
child: Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () async {
if (_formKey.currentState!.validate()) {
String fullPhoneNumber =
selectedCountryCode + _phoneController.text;
phoneController.updatePhone(fullPhoneNumber);
await phoneController.register();
if(phoneController.model.status != 'failed') {
context.go('/onboarding/verify');
}
}
},
child: Text('Send Code', style: TextStyle(fontSize: 16)),
),
),
SizedBox(width: 16),
Expanded(
child: TextButton(
onPressed: () {
context.go('/onboarding/login');
},
child: Text(
'Go to Login',
style: TextStyle(fontSize: 16),
), ),
), SizedBox(height: 10),
Text(
'We will send you a verification code to this number',
style: TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
Image.asset('assets/phone.png', width: 300),
SizedBox(height: 20),
_buildPhoneField(),
SizedBox(height: 30),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 25),
child: Skeletonizer(
enabled: phoneController.model.isLoading,
child: Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () async {
if (_formKey.currentState!.validate()) {
String fullPhoneNumber =
selectedCountryCode + _phoneController.text;
phoneController.updatePhone(fullPhoneNumber);
await phoneController.register();
if(phoneController.model.status != 'failed') {
context.go('/onboarding/verify');
}
}
},
child: Text('Send Code', style: TextStyle(fontSize: 16)),
),
),
SizedBox(width: 16),
Expanded(
child: OutlinedButton(
onPressed: () {
context.go('/onboarding/login');
},
child: Text(
'Go to Login',
style: TextStyle(fontSize: 16),
),
),
),
],
),
),
),
SizedBox(height: 10),
TextButton(
onPressed: () {
context.go('/');
},
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
),
],
), ),
], );
), }
), ),
), ),
SizedBox(height: 5), ),
TextButton(
onPressed: () {
context.go('/');
},
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
),
],
), ),
), ),
); );

View File

@@ -1,10 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:google_sign_in/google_sign_in.dart'; import 'package:google_sign_in/google_sign_in.dart';
import 'package:google_sign_in_web/web_only.dart' as web;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/onboarding/onboarding_controller.dart'; import 'package:qpay/screens/onboarding/onboarding_controller.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
@@ -17,6 +20,7 @@ class RegisterScreen extends StatefulWidget {
class _RegisterScreenState extends State<RegisterScreen> { class _RegisterScreenState extends State<RegisterScreen> {
late OnboardingController onboardingController; late OnboardingController onboardingController;
late GoogleSignIn googleSignIn;
bool _loading = false; bool _loading = false;
@@ -32,17 +36,22 @@ class _RegisterScreenState extends State<RegisterScreen> {
listen: false, listen: false,
); );
// onboardingController.resetState(); // onboardingController.resetState();
googleSignIn = GoogleSignIn.instance;
if(kIsWeb){
googleSignIn.initialize();
googleSignIn.authenticationEvents
.listen(_handleAuthenticationEvent)
.onError(_handleAuthenticationError);
}else {
unawaited(googleSignIn.initialize(serverClientId: dotenv.env['CLIENTID']));
}
} }
void signInWithGoogle() { void signInWithGoogle() {
final GoogleSignIn googleSignIn = GoogleSignIn.instance;
unawaited(googleSignIn.initialize(serverClientId: dotenv.env['CLIENTID']));
if (GoogleSignIn.instance.supportsAuthenticate()){ if (GoogleSignIn.instance.supportsAuthenticate()){
unawaited(GoogleSignIn.instance.authenticate().then((value) { unawaited(GoogleSignIn.instance.authenticate().then((value) {
print(value);
onboardingController.updateGoogleUser(value);
googleSignIn.authenticationEvents googleSignIn.authenticationEvents
.listen(_handleAuthenticationEvent) .listen(_handleAuthenticationEvent)
.onError(_handleAuthenticationError); .onError(_handleAuthenticationError);
@@ -51,9 +60,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
} }
} }
Future<void> _handleAuthenticationEvent( Future<void> _handleAuthenticationEvent(GoogleSignInAuthenticationEvent event) async {
GoogleSignInAuthenticationEvent event,
) async {
// #docregion CheckAuthorization // #docregion CheckAuthorization
final GoogleSignInAccount? user = // ... final GoogleSignInAccount? user = // ...
// #enddocregion CheckAuthorization // #enddocregion CheckAuthorization
@@ -68,6 +75,9 @@ class _RegisterScreenState extends State<RegisterScreen> {
?.authorizationClient ?.authorizationClient
.authorizationForScopes(scopes); .authorizationForScopes(scopes);
// #enddocregion CheckAuthorization // #enddocregion CheckAuthorization
print(user);
onboardingController.updateGoogleUser(user as GoogleSignInAccount);
} }
Future<void> _handleAuthenticationError(Object e) async { Future<void> _handleAuthenticationError(Object e) async {
@@ -83,170 +93,184 @@ class _RegisterScreenState extends State<RegisterScreen> {
body: SingleChildScrollView( body: SingleChildScrollView(
child: Padding( child: Padding(
padding: const EdgeInsets.all(25), padding: const EdgeInsets.all(25),
child: Column( child: Center(
mainAxisAlignment: MainAxisAlignment.center, child: LayoutBuilder(
crossAxisAlignment: CrossAxisAlignment.center, builder: (context, constraints) {
children: [ final maxWidth = constraints.maxWidth > ResponsivePolicy.md
SizedBox(height: 75), ? ResponsivePolicy.md.toDouble()
Text( : constraints.maxWidth;
'Registration', return SizedBox(
style: TextStyle( width: maxWidth,
fontSize: 30, child: Column(
fontWeight: FontWeight.bold, mainAxisAlignment: MainAxisAlignment.center,
color: Theme.of(context).colorScheme.primary, crossAxisAlignment: CrossAxisAlignment.center,
),
),
SizedBox(height: 5),
Text(
"Let's start by getting some of your details online to get you started",
style: TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
Image.asset('assets/social.png', width: 300),
SizedBox(height: 20),
// Social Login Buttons
if(onboardingController.model.googleUser == null)
Column(
children: [
Row(
children: [ children: [
Expanded(child: Divider(color: Colors.grey.shade300)), SizedBox(height: 75),
Padding( Text(
padding: EdgeInsets.symmetric(horizontal: 16), 'Registration',
child: Text( style: TextStyle(
'Choose from the options below to continue', fontSize: 30,
style: TextStyle( fontWeight: FontWeight.bold,
color: Colors.grey.shade600, color: Theme.of(context).colorScheme.primary,
fontSize: 14,
),
textAlign: TextAlign.center,
), ),
), ),
Expanded(child: Divider(color: Colors.grey.shade300)), SizedBox(height: 5),
], Text(
), "Let's start by getting some of your online details to get you started",
SizedBox(height: 20), style: TextStyle(fontSize: 18),
Skeletonizer( textAlign: TextAlign.center,
enabled: _loading, ),
child: Row( Image.asset('assets/social.png', width: 300),
children: [ SizedBox(height: 20),
Expanded(
child: OutlinedButton.icon( // Social Login Buttons
onPressed: () { if(onboardingController.model.googleUser == null)
signInWithGoogle(); Column(
}, children: [
icon: Image.asset( Row(
'assets/google.png', children: [
width: 20, Expanded(child: Divider(color: Colors.grey.shade300)),
height: 20, Padding(
), padding: EdgeInsets.symmetric(horizontal: 16),
label: Text('Google', style: TextStyle(fontSize: 16)), child: Text(
style: OutlinedButton.styleFrom( 'Choose from the options below to continue',
padding: EdgeInsets.symmetric(vertical: 16), style: TextStyle(
side: BorderSide(color: Colors.grey.shade300), color: Colors.grey.shade600,
shape: RoundedRectangleBorder( fontSize: 14,
borderRadius: BorderRadius.circular(12), ),
textAlign: TextAlign.center,
),
),
Expanded(child: Divider(color: Colors.grey.shade300)),
],
),
SizedBox(height: 20),
if(GoogleSignIn.instance.supportsAuthenticate())
_buildGoogleButton()
else ...<Widget>[
if (kIsWeb)
web.renderButton()
],
],
),
if(onboardingController.model.googleUser != null)
Column(
children: [
SizedBox(
width: 100,
child: Divider(
color: Colors.grey.shade300,
thickness: 1,
), ),
), ),
), SizedBox(height: 20,),
Text(
"Welcome, ${onboardingController.model.googleUser?.displayName}",
style: TextStyle(fontSize: 25),
textAlign: TextAlign.center,
),
SizedBox(height: 10,),
Text("Tap Proceed to continue", style: TextStyle(fontSize: 18),)
],
),
SizedBox(height: 50),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 25),
child: Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () {
if (onboardingController.model.googleUser == null) {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Sign-In Required'),
content: const Text(
"Please sign in with one of the social media platforms to proceed."),
actions: <Widget>[
TextButton(
child: const Text('OK'),
onPressed: () {
Navigator.of(dialogContext).pop(); // Close the dialog
},
),
],
);
},
);
} else {
context.go('/onboarding/phone');
}
},
child: Text('Proceed', style: TextStyle(fontSize: 16)),
),
),
SizedBox(width: 16),
Expanded(
child: OutlinedButton(
onPressed: () {
context.go('/onboarding/login');
},
child: Text(
'Go to Login',
style: TextStyle(fontSize: 16),
),
),
),
],
), ),
SizedBox(width: 16),
],
),
),
],
),
if(onboardingController.model.googleUser != null)
Column(
children: [
SizedBox(
width: 100,
child: Divider(
color: Colors.grey.shade300,
thickness: 1,
), ),
), SizedBox(height: 10),
SizedBox(height: 20,), TextButton(
Text( onPressed: () {
"Welcome, ${onboardingController.model.googleUser?.displayName}", context.go('/');
style: TextStyle(fontSize: 25), },
textAlign: TextAlign.center, child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
), ),
SizedBox(height: 10,),
Text("Tap Proceed to continue", style: TextStyle(fontSize: 18),)
],
),
SizedBox(height: 40),
], ],
),
);
}
),
), ),
), ),
), ),
bottomNavigationBar: SizedBox(
height: 130,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 25),
child: Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () {
if (onboardingController.model.googleUser == null) {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Sign-In Required'),
content: const Text(
"Please sign in with one of the social media platforms to proceed."),
actions: <Widget>[
TextButton(
child: const Text('OK'),
onPressed: () {
Navigator.of(dialogContext).pop(); // Close the dialog
},
),
],
);
},
);
} else {
context.go('/onboarding/phone');
}
},
child: Text('Proceed', style: TextStyle(fontSize: 16)),
),
),
SizedBox(width: 16),
Expanded(
child: TextButton(
onPressed: () {
context.go('/onboarding/login');
},
child: Text(
'Go to Login',
style: TextStyle(fontSize: 16),
),
),
),
],
),
),
SizedBox(height: 5),
TextButton(
onPressed: () {
context.go('/');
},
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
),
],
),
),
); );
} }
); );
} }
Widget _buildGoogleButton(){
return Skeletonizer(
enabled: _loading,
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
signInWithGoogle();
},
icon: Image.asset(
'assets/google.png',
width: 20,
height: 20,
),
label: Text('Google', style: TextStyle(fontSize: 16)),
style: OutlinedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 16),
side: BorderSide(color: Colors.grey.shade300),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
SizedBox(width: 16),
],
),
);
}
} }

View File

@@ -1,5 +1,6 @@
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:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/onboarding/verify/verify_controller.dart'; import 'package:qpay/screens/onboarding/verify/verify_controller.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
@@ -59,169 +60,162 @@ class _VerifyScreenState extends State<VerifyScreen> {
body: SingleChildScrollView( body: SingleChildScrollView(
child: Padding( child: Padding(
padding: const EdgeInsets.all(25), padding: const EdgeInsets.all(25),
child: Column( child: Center(
mainAxisAlignment: MainAxisAlignment.center, child: LayoutBuilder(
crossAxisAlignment: CrossAxisAlignment.center, builder: (context, constraints) {
children: [ final maxWidth = constraints.maxWidth > ResponsivePolicy.md
SizedBox(height: 75), ? ResponsivePolicy.md.toDouble()
Text( : constraints.maxWidth;
'Verify Your Account', return SizedBox(
style: TextStyle( width: maxWidth,
fontSize: 30, child: Column(
fontWeight: FontWeight.bold, mainAxisAlignment: MainAxisAlignment.center,
color: Theme.of(context).colorScheme.primary, crossAxisAlignment: CrossAxisAlignment.center,
), children: [
), SizedBox(height: 75),
SizedBox(height: 5), Text(
Text( 'Verify Your Account',
'Enter the 6-digit code sent to your phone/email', style: TextStyle(
style: TextStyle(fontSize: 18), fontSize: 30,
textAlign: TextAlign.center, fontWeight: FontWeight.bold,
), color: Theme.of(context).colorScheme.primary,
Image.asset('assets/verify.png', width: 300),
// Verification Code Input
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(
6,
(index) => SizedBox(
width: 50,
child: TextFormField(
controller: _codeControllers[index],
focusNode: _focusNodes[index],
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
maxLength: 1,
decoration: InputDecoration(
counterText: '',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
), ),
focusedBorder: OutlineInputBorder( ),
borderRadius: BorderRadius.circular(12), SizedBox(height: 5),
borderSide: BorderSide( Text(
color: Theme.of(context).colorScheme.primary, 'Enter the 6-digit code sent to your phone/email',
width: 2, style: TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
Image.asset('assets/verify.png', width: 300),
// Verification Code Input
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(
6,
(index) => SizedBox(
width: 50,
child: TextFormField(
controller: _codeControllers[index],
focusNode: _focusNodes[index],
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
maxLength: 1,
decoration: InputDecoration(
counterText: '',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
contentPadding: EdgeInsets.symmetric(vertical: 16),
),
onChanged: (value) => _onCodeChanged(value, index),
),
), ),
), ),
contentPadding: EdgeInsets.symmetric(vertical: 16),
), ),
onChanged: (value) => _onCodeChanged(value, index), SizedBox(height: 5),
), if(errorMessage != null)
), Text(errorMessage ?? '', style: TextStyle(color: Colors.red)),
), SizedBox(height: 30),
), // Resend Code Section
SizedBox(height: 5), Row(
if(errorMessage != null) mainAxisAlignment: MainAxisAlignment.center,
Text(errorMessage ?? '', style: TextStyle(color: Colors.red)), children: [
SizedBox(height: 30), Text(
// Resend Code Section "Didn't receive the code? ",
Row( style: TextStyle(color: Colors.grey.shade600, fontSize: 16),
mainAxisAlignment: MainAxisAlignment.center, ),
children: [ TextButton(
Text( onPressed: () {
"Didn't receive the code? ", verifyController.resendOtp();
style: TextStyle(color: Colors.grey.shade600, fontSize: 16), },
), child: Text(
TextButton( 'Resend Code',
onPressed: () { style: TextStyle(
verifyController.resendOtp(); color: Theme.of(context).colorScheme.primary,
}, fontSize: 16,
child: Text( fontWeight: FontWeight.w600,
'Resend Code', ),
style: TextStyle( ),
color: Theme.of(context).colorScheme.primary, ),
fontSize: 16, ],
fontWeight: FontWeight.w600,
), ),
), // SizedBox(height: 20),
), // // Timer for resend (optional)
], // Text(
), // 'Resend available in 2:30',
// SizedBox(height: 20), // style: TextStyle(color: Colors.grey.shade500, fontSize: 14),
// // Timer for resend (optional) // ),
// Text( SizedBox(height: 40),
// 'Resend available in 2:30', Padding(
// style: TextStyle(color: Colors.grey.shade500, fontSize: 14), padding: const EdgeInsets.symmetric(horizontal: 25),
// ), child: Skeletonizer(
SizedBox(height: 40), enabled: verifyController.model.isLoading,
], child: Row(
), children: [
), Expanded(
), child: ElevatedButton(
bottomNavigationBar: SizedBox( onPressed: () async {
height: 130, showErrorMessage(null);
child: Column( String code = '';
children: [ for (var controller in _codeControllers) {
Padding( code += controller.text;
padding: const EdgeInsets.symmetric(horizontal: 25), }
child: Skeletonizer(
enabled: verifyController.model.isLoading,
child: Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () async {
showErrorMessage(null);
String code = '';
for (var controller in _codeControllers) {
code += controller.text;
}
if(code.length != 6) { if(code.length != 6) {
showErrorMessage('Invalid code'); showErrorMessage('Invalid code');
return; return;
} }
verifyController.updateCode(code); verifyController.updateCode(code);
await verifyController.verifyOtp(); await verifyController.verifyOtp();
if(verifyController.model.status == 'done') { if(verifyController.model.status == 'done') {
context.go('/onboarding/complete'); context.go('/onboarding/complete');
} }
}, },
style: ElevatedButton.styleFrom( child: Text(
padding: EdgeInsets.symmetric(vertical: 16), 'Verify Code',
shape: RoundedRectangleBorder( style: TextStyle(fontSize: 16),
borderRadius: BorderRadius.circular(12), ),
),
),
SizedBox(width: 16),
Expanded(
child: OutlinedButton(
onPressed: () {
context.go('/onboarding/login');
},
child: Text(
'Back to Login',
style: TextStyle(fontSize: 16),
),
),
),
],
), ),
), ),
child: Text(
'Verify Code',
style: TextStyle(fontSize: 16),
),
), ),
), SizedBox(height: 10),
SizedBox(width: 16), TextButton(
Expanded(
child: OutlinedButton(
onPressed: () { onPressed: () {
context.go('/onboarding/login'); context.go('/');
}, },
style: OutlinedButton.styleFrom( child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
padding: EdgeInsets.symmetric(vertical: 16),
side: BorderSide(color: Colors.grey.shade300),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'Back to Login',
style: TextStyle(fontSize: 16),
),
), ),
),
], ],
), ),
), );
}
), ),
SizedBox(height: 20), ),
TextButton(
onPressed: () {
context.go('/');
},
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
),
],
), ),
), ),
); );

View File

@@ -95,7 +95,7 @@ class PayController extends ChangeNotifier {
transactionController.model.formData.copyWith( transactionController.model.formData.copyWith(
type: "CONFIRM", type: "CONFIRM",
billClientId: model.selectedProvider?.clientId ?? '', billClientId: model.selectedProvider?.clientId ?? '',
debitRef: 'peak', debitRef: 'Velocity',
debitCurrency: 'USD', debitCurrency: 'USD',
creditAccount: transactionController.model.formData.creditAccount, creditAccount: transactionController.model.formData.creditAccount,
creditPhone: transactionController.model.formData.creditPhone, creditPhone: transactionController.model.formData.creditPhone,

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart'; 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/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:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
@@ -178,105 +179,127 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
position: _slideAnimation, position: _slideAnimation,
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Center(
crossAxisAlignment: CrossAxisAlignment.start, child: LayoutBuilder(
children: [ builder: (context, constraints) {
Text( return SizedBox(
"TRANSACTIONS DETAILS", width: double.parse(constraints.maxWidth > ResponsivePolicy.md ?
style: TextStyle( ResponsivePolicy.md.toString() :
fontSize: 16, constraints.maxWidth.toString()),
fontWeight: FontWeight.bold, child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
), children: [
const SizedBox(height: 20), Container(
Row( decoration: BoxDecoration(
mainAxisAlignment: MainAxisAlignment.spaceBetween, border: Border.all(color: Colors.black87.withAlpha(20)),
children: [ borderRadius: BorderRadius.circular(12),
Text( ),
"Provider", padding: EdgeInsets.symmetric(vertical: 10, horizontal: 20),
style: TextStyle( child: Column(
fontSize: 16, crossAxisAlignment: CrossAxisAlignment.start,
fontWeight: FontWeight.bold, children: [
), Text(
), "TRANSACTIONS DETAILS",
Text( style: TextStyle(
controller.model.selectedProvider?.name ?? "", fontSize: 16,
style: TextStyle(fontSize: 16), fontWeight: FontWeight.bold,
), ),
], ),
), const SizedBox(height: 10),
const SizedBox(height: 10), Row(
Row( mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
children: [ Text(
Text( "Provider",
controller style: TextStyle(
.model fontSize: 16,
.selectedProvider fontWeight: FontWeight.bold,
?.accountFieldName ?? ),
"", ),
style: TextStyle( Text(
fontSize: 16, controller.model.selectedProvider?.name ?? "",
fontWeight: FontWeight.bold, style: TextStyle(fontSize: 16),
), ),
), ],
Text( ),
transactionController const SizedBox(height: 10),
.model Row(
.formData mainAxisAlignment: MainAxisAlignment.spaceBetween,
.creditAccount, children: [
style: TextStyle(fontSize: 16), Text(
), controller
], .model
), .selectedProvider
const SizedBox(height: 10), ?.accountFieldName ??
Divider(color: Theme.of(context).colorScheme.primary), "",
InkWell( style: TextStyle(
customBorder: RoundedRectangleBorder( fontSize: 16,
borderRadius: BorderRadius.circular(12), fontWeight: FontWeight.bold,
), ),
onTap: () { ),
setState(() { Text(
showAdditionalRecipientDetails = transactionController
!showAdditionalRecipientDetails; .model
}); .formData
}, .creditAccount,
child: Row( style: TextStyle(fontSize: 16),
mainAxisAlignment: MainAxisAlignment.center, ),
children: [ ],
IconButton( ),
onPressed: () {}, ],
icon: Icon( ),
showAdditionalRecipientDetails
? Icons.arrow_drop_up
: Icons.arrow_drop_down,
), ),
color: Theme.of(context).colorScheme.primary, SizedBox(height: 20),
), InkWell(
Text( customBorder: RoundedRectangleBorder(
showAdditionalRecipientDetails borderRadius: BorderRadius.circular(12),
? "Hide additional recipient details" ),
: "Show additional recipient details", onTap: () {
style: TextStyle(fontSize: 16), setState(() {
), showAdditionalRecipientDetails =
], !showAdditionalRecipientDetails;
), });
), },
if (showAdditionalRecipientDetails) child: Row(
_buildAdditionalRecipientDetails(), mainAxisAlignment: MainAxisAlignment.center,
const SizedBox(height: 7), children: [
if (controller.model.products.isNotEmpty) IconButton(
_buildBillProductSelector(controller), onPressed: () {},
const SizedBox(height: 7), icon: Icon(
_buildPhoneField(), showAdditionalRecipientDetails
const SizedBox(height: 7), ? Icons.arrow_drop_up
_buildAmountField(), : Icons.arrow_drop_down,
const SizedBox(height: 20), ),
// _buildPayButton(controller), color: Theme.of(context).colorScheme.primary,
...controller.model.paymentProcessors.map( ),
(e) => _buildPaymentProcessorItem(e, context), Text(
), showAdditionalRecipientDetails
], ? "Hide additional recipient details"
: "Show additional recipient details",
style: TextStyle(fontSize: 16),
),
],
),
),
if (showAdditionalRecipientDetails)
_buildAdditionalRecipientDetails(),
const SizedBox(height: 7),
if (controller.model.products.isNotEmpty)
_buildBillProductSelector(controller),
const SizedBox(height: 7),
_buildPhoneField(),
const SizedBox(height: 7),
_buildAmountField(),
const SizedBox(height: 20),
// _buildPayButton(controller),
...controller.model.paymentProcessors.map(
(e) => _buildPaymentProcessorItem(e, context),
),
],
),
);
}
),
), ),
), ),
), ),

View File

@@ -0,0 +1,126 @@
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/gateway/gateway_controller.dart';
import 'package:qpay/screens/transaction_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
class PollScreen extends StatefulWidget {
const PollScreen({super.key});
@override
State<PollScreen> createState() => _PollScreenState();
}
class _PollScreenState extends State<PollScreen> {
late GatewayController controller;
late TransactionController transactionController;
bool integrating = false;
@override
void initState() {
super.initState();
controller = GatewayController(context);
transactionController = Provider.of<TransactionController>(
context,
listen: false,
);
controller.pollTransaction(transactionController.model.confirmationData['id']);
}
void _showErrorSnackBar(String? message) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message.toString()),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.deepOrange,
),
);
});
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Completing your transaction'),
),
body: ListenableBuilder(
listenable: controller,
builder: (context, child) {
if (controller.model.status == 'SUCCESS') {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/integration');
});
}
return Container(
padding: EdgeInsets.all(50),
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: double.parse(constraints.maxWidth > ResponsivePolicy.md ?
ResponsivePolicy.md.toString() :
constraints.maxWidth.toString()),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 75),
Column(
children: [
Text(
'We\'re checking with our gateway if your transaction was successful. This won\'t take long',
style: TextStyle(fontSize: 20),
textAlign: TextAlign.center,
),
SizedBox(height: 50,),
Image.asset('assets/velocity-animation.gif', width: 150),
SizedBox(height: 50),
if(controller.model.status == 'FAILED')
Column(
children: [
Text(
'We failed to check your transaction status',
style: TextStyle(fontSize: 20),
textAlign: TextAlign.center,
),
SizedBox(height: 20,),
Skeletonizer(
enabled: controller.model.isLoading,
child: OutlinedButton(
child: Text('Retry'),
onPressed: () {
controller.pollTransaction(transactionController.model.confirmationData['id']);
},
),
),
],
),
],
),
],
),
);
}
),
),
);
},
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart'; 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/screens/transaction_controller.dart'; import 'package:qpay/screens/transaction_controller.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:qpay/screens/recipient/recipients_controller.dart'; import 'package:qpay/screens/recipient/recipients_controller.dart';
@@ -83,81 +84,107 @@ class _RecipientsScreenState extends State<RecipientsScreen>
return SafeArea( return SafeArea(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Center(
crossAxisAlignment: CrossAxisAlignment.start, child: LayoutBuilder(
children: [ builder: (context, constraints) {
Text( return SizedBox(
"TRANSACTIONS DETAILS", width: double.parse(constraints.maxWidth > ResponsivePolicy.md ?
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ResponsivePolicy.md.toString() :
), constraints.maxWidth.toString()),
const SizedBox(height: 20), child: Column(
Row( crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
children: [ Container(
Text("Provider", style: TextStyle(fontSize: 16)), decoration: BoxDecoration(
Text( border: Border.all(color: Colors.black87.withAlpha(20)),
controller.model.selectedProvider?.name ?? "", borderRadius: BorderRadius.circular(12),
style: TextStyle(fontSize: 16), ),
), padding: EdgeInsets.symmetric(vertical: 10, horizontal: 20),
],
), child: Column(
const SizedBox(height: 20), crossAxisAlignment: CrossAxisAlignment.start,
Divider(color: Theme.of(context).colorScheme.primary), children: [
const SizedBox(height: 20), Text(
_buildAccountField(controller), "TRANSACTIONS DETAILS",
const SizedBox(height: 20), style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
controller.model.creditAccount != null &&
controller.model.creditAccount!.isNotEmpty
? SlideTransition(
position: _alignListAnimations[0],
child: ElevatedButton(
onPressed: () {
String phone = '';
if (!transactionController
.model
.selectedProvider!
.requiresAccount) {
phone = _accountController.text;
}
transactionController.model.formData =
transactionController.model.formData.copyWith(
creditAccount: _accountController.text,
creditPhone: phone,
);
context.push("/make-payment");
},
style: ElevatedButton.styleFrom(
backgroundColor:
Theme.of(context).colorScheme.primary,
foregroundColor:
Theme.of(context).colorScheme.onPrimary,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"Use as new recipient",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
), ),
), const SizedBox(height: 10),
SizedBox(width: 5), Row(
Icon(Icons.arrow_forward_ios, size: 12), mainAxisAlignment: MainAxisAlignment.spaceBetween,
], children: [
Text("Provider",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
)),
Text(
controller.model.selectedProvider?.name ?? "",
style: TextStyle(fontSize: 16),
),
],
),
],
),
), ),
), const SizedBox(height: 20),
) _buildAccountField(controller),
: SizedBox(), const SizedBox(height: 20),
const SizedBox(height: 20), controller.model.creditAccount != null &&
Text( controller.model.creditAccount!.isNotEmpty
"Recent Recipients", ? SlideTransition(
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), position: _alignListAnimations[0],
), child: ElevatedButton(
const SizedBox(height: 10), onPressed: () {
_buildRecipientItems(controller), String phone = '';
], if (!transactionController
.model
.selectedProvider!
.requiresAccount) {
phone = _accountController.text;
}
transactionController.model.formData =
transactionController.model.formData.copyWith(
creditAccount: _accountController.text,
creditPhone: phone,
);
context.push("/make-payment");
},
style: ElevatedButton.styleFrom(
backgroundColor:
Theme.of(context).colorScheme.primary,
foregroundColor:
Theme.of(context).colorScheme.onPrimary,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"Use as new recipient",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
SizedBox(width: 5),
Icon(Icons.arrow_forward_ios, size: 12),
],
),
),
)
: SizedBox(),
const SizedBox(height: 20),
Text(
"Recent Recipients",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
_buildRecipientItems(controller),
],
),
);
}
),
), ),
), ),
); );

View File

@@ -10,6 +10,7 @@ import google_sign_in_ios
import path_provider_foundation import path_provider_foundation
import share_plus import share_plus
import shared_preferences_foundation import shared_preferences_foundation
import url_launcher_macos
import webview_flutter_wkwebview import webview_flutter_wkwebview
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
@@ -18,5 +19,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
} }

View File

@@ -153,6 +153,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.6" version: "3.0.6"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -377,7 +385,7 @@ packages:
source: hosted source: hosted
version: "3.1.0" version: "3.1.0"
google_sign_in_web: google_sign_in_web:
dependency: transitive dependency: "direct main"
description: description:
name: google_sign_in_web name: google_sign_in_web
sha256: "2fc1f941e6443b2d6984f4056a727a3eaeab15d8ee99ba7125d79029be75a1da" sha256: "2fc1f941e6443b2d6984f4056a727a3eaeab15d8ee99ba7125d79029be75a1da"
@@ -392,6 +400,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" version: "2.3.2"
html:
dependency: "direct main"
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http: http:
dependency: transitive dependency: transitive
description: description:
@@ -861,6 +877,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
url: "https://pub.dev"
source: hosted
version: "6.3.28"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
url: "https://pub.dev"
source: hosted
version: "6.3.6"
url_launcher_linux: url_launcher_linux:
dependency: transitive dependency: transitive
description: description:
@@ -869,6 +909,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.1" version: "3.2.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface: url_launcher_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -950,7 +998,7 @@ packages:
source: hosted source: hosted
version: "1.1.4" version: "1.1.4"
web: web:
dependency: transitive dependency: "direct main"
description: description:
name: web name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"

View File

@@ -1,5 +1,5 @@
name: qpay name: qpay
description: "Peak payments solutions" description: "Velocity payments solutions"
# The following line prevents the package from being accidentally published to # The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages. # pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev publish_to: 'none' # Remove this line if you wish to publish to pub.dev
@@ -55,6 +55,10 @@ dependencies:
gif_view: ^0.4.0 gif_view: ^0.4.0
google_sign_in: ^7.1.1 google_sign_in: ^7.1.1
firebase_core: ^4.1.0 firebase_core: ^4.1.0
url_launcher: ^6.3.2
html: ^0.15.6
web: ^1.1.1
google_sign_in_web: ^1.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 85 KiB

View File

@@ -20,6 +20,7 @@
<meta content="IE=Edge" http-equiv="X-UA-Compatible"> <meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project."> <meta name="description" content="A new Flutter project.">
<meta name="color-scheme" content="light"> <meta name="color-scheme" content="light">
<meta name="google-signin-client_id" content="77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com">
<!-- iOS meta tags & icons --> <!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
@@ -30,8 +31,27 @@
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/> <link rel="icon" type="image/png" href="favicon.png"/>
<title>qpay</title> <title>Velocity</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<script src="https://na.gateway.mastercard.com/static/checkout/checkout.min.js">
</script>
<!-- <script src="https://test-gateway.mastercard.com/static/checkout/checkout.min.js">-->
<!-- </script>-->
<script type="text/javascript">
function configure(sessionID) {
Checkout.configure({
session: {
id: sessionID
}
});
}
function showEmbeddedPage() {
Checkout.showEmbeddedPage('#embed-target');
}
</script>
</head> </head>
<body> <body>
<script src="flutter_bootstrap.js" async></script> <script src="flutter_bootstrap.js" async></script>