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

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:provider/provider.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/gateway/gateway_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/verify/verify_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/recipient/recipients_screen.dart';
import 'package:qpay/screens/transaction_controller.dart';
import 'screens/gateway/gateway_web_screen.dart';
import 'screens/home/home_screen.dart';
import 'screens/history/history_screen.dart';
import 'screens/profile_screen.dart';
@@ -66,7 +69,7 @@ class _MyAppState extends State<MyApp> {
Widget build(BuildContext context) {
if (_showSplash) {
return MaterialApp(
title: 'Peak',
title: 'Velocity',
theme: AppTheme.lightTheme,
themeMode: ThemeMode.light,
home: SplashScreen(onSplashComplete: _onSplashComplete),
@@ -75,7 +78,7 @@ class _MyAppState extends State<MyApp> {
}
return MaterialApp.router(
title: 'Peak',
title: 'Velocity',
theme: AppTheme.lightTheme,
themeMode: ThemeMode.light, // Force light mode regardless of OS setting
debugShowCheckedModeBanner: false,
@@ -112,6 +115,14 @@ class _MyAppState extends State<MyApp> {
path: '/gateway',
builder: (context, state) => const GatewayScreen(),
),
GoRoute(
path: '/gateway-web',
builder: (context, state) => const GatewayWebScreen(),
),
GoRoute(
path: '/poll',
builder: (context, state) => const PollScreen(),
),
GoRoute(
path: '/receipt',
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:freezed_annotation/freezed_annotation.dart';
import 'package:go_router/go_router.dart';
@@ -81,10 +82,13 @@ class ConfirmController extends ChangeNotifier {
transactionController.updateReceiptData(response);
if (transactionController.model.selectedPaymentProcessor.authType ==
"WEB") {
if (transactionController.model.selectedPaymentProcessor.authType == "WEB") {
if (context.mounted) {
context.push('/gateway');
if(kIsWeb){
context.push('/gateway-web');
}else {
context.push('/gateway');
}
}
} else {
await pollTransaction(transactionController.model.receiptData?['id']);

View File

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

View File

@@ -76,15 +76,22 @@ class GatewayController extends ChangeNotifier {
}
Future<void> pollTransaction(String uid) async {
// only poll for 5 minutes
int max = 30;
while (!model.isCancelled) {
max++;
// Check cancellation flag instead of true
// 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
if (model.isCancelled) break;
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_dotenv/flutter_dotenv.dart';
import 'package:go_router/go_router.dart';
@@ -17,6 +15,7 @@ class _GatewayScreenState extends State<GatewayScreen> {
late WebViewController _webViewController;
late GatewayController controller;
late String url;
String? sessionID;
bool integrationStarted = false;
@@ -24,63 +23,71 @@ class _GatewayScreenState extends State<GatewayScreen> {
void initState() {
super.initState();
controller = GatewayController(context);
// https://na.gateway.mastercard.com/checkout/pay/SESSION0002220827459I8059951J88?checkoutVersion=1.0.0
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']!);
if(simulate) {
sleep(Duration(seconds: 10));
if (simulate) {
await Future.delayed(Duration(seconds: 10));
String targetUrl = url.replaceAll("/pay/", "/receipt/");
_webViewController.loadRequest(Uri.parse(targetUrl));
}
}
void _initWebView(String url) {
_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!}");
void _navigateWhenDone(String url) {
if (url.contains("/checkout/receipt/")) {
if (integrationStarted) {
return;
}
if(url.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),
),
);
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;
});
setState(() {
integrationStarted = true;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/integration');
});
}
}
),
)
..loadRequest(Uri.parse(url));
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/integration');
});
}
}
@override
@@ -94,15 +101,14 @@ class _GatewayScreenState extends State<GatewayScreen> {
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(),
leading: context.canPop()
? IconButton(
onPressed: () {
context.pop();
},
icon: const Icon(Icons.arrow_back),
)
: SizedBox.shrink(),
),
body: ListenableBuilder(
listenable: controller,
@@ -112,9 +118,12 @@ class _GatewayScreenState extends State<GatewayScreen> {
context.go('/integration');
});
}
return controller.model.isLoading
? 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": "",
"debitAccount": "",
"debitCurrency": "USD",
"debitRef": "peak",
"debitRef": "Velocity",
"creditPhone": "",
"creditAccount": "07088597534",
"billClientId": "powertel_zesa",

View File

@@ -1,6 +1,7 @@
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/transaction_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -23,7 +24,6 @@ class _HistoryScreenState extends State<HistoryScreen>
final TextEditingController _searchController = TextEditingController();
final Map<String, String> _queryParams = {};
final List<Animation<Offset>> _alignListAnimations = [];
final int _pageSize = 8;
@override
@@ -38,21 +38,9 @@ class _HistoryScreenState extends State<HistoryScreen>
);
_controller = AnimationController(
duration: const Duration(milliseconds: 600),
duration: const Duration(milliseconds: 3000),
vsync: this,
)..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 {
@@ -90,56 +78,78 @@ class _HistoryScreenState extends State<HistoryScreen>
builder: (context, child) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_buildSearchField(controller),
if (controller.model.transactions.isEmpty)
Column(
children: [
SizedBox(height: 30),
Text(
'No transactions yet. Make a payment to see your recent activity.',
style: TextStyle(fontSize: 16),
textAlign: TextAlign.center,
),
],
),
if (controller.model.transactions.isNotEmpty)
Column(
children: [
ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
...controller.model.transactions.map(
(e) => _buildTransactionItem(e, context),
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: double.parse(
constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toString()
: constraints.maxWidth.toString(),
),
child: Column(
children: [
_buildSearchField(controller),
SizedBox(height: 10),
if (controller.model.transactions.isEmpty)
Column(
children: [
SizedBox(height: 30),
Text(
'No transactions yet. Make a payment to see your recent activity.',
style: TextStyle(fontSize: 16),
textAlign: TextAlign.center,
),
],
),
],
),
SizedBox(height: 10),
if (controller.model.pageableModel != null &&
controller.model.pageableModel!.number <
controller.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',
});
},
),
],
),
],
if (controller.model.transactions.isNotEmpty)
Column(
children: [
ListView(
shrinkWrap: true,
physics:
const NeverScrollableScrollPhysics(),
children: [
...controller.model.transactions.map(
(e) =>
_buildTransactionItem(e, context),
),
],
),
SizedBox(height: 10),
if (controller.model.pageableModel !=
null &&
controller.model.pageableModel!.number <
controller
.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),
),
),
suffixIcon:
_searchController.text.isNotEmpty
? IconButton(
onPressed: () {
_searchController.clear();
_queryParams.clear();
controller.getTransactions({
'userId': prefs.getString("userId")!,
});
},
icon: Icon(Icons.clear),
color: Theme.of(context).colorScheme.primary,
)
: null,
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
onPressed: () {
_searchController.clear();
_queryParams.clear();
controller.getTransactions({
'userId': prefs.getString("userId")!,
});
},
icon: Icon(Icons.clear),
color: Theme.of(context).colorScheme.primary,
)
: null,
),
onChanged: (value) {
// controller.updateCreditAccount(value);
@@ -227,8 +236,17 @@ class _HistoryScreenState extends State<HistoryScreen>
}
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(
position: _alignListAnimations[0],
position: animation,
child: Skeletonizer(
enabled: controller.model.isLoading,
child: InkWell(
@@ -260,18 +278,17 @@ class _HistoryScreenState extends State<HistoryScreen>
),
SizedBox(width: 10),
Icon(
e['status'] == 'SUCCESS'
e['integrationStatus'] == 'SUCCESS'
? Icons.check_circle
: e['status'] == 'PENDING'
: e['integrationStatus'] == 'PENDING'
? Icons.pending
: Icons.cancel,
size: 16,
color:
e['status'] == 'SUCCESS'
? Colors.green
: e['status'] == 'PENDING'
? Colors.orange
: Colors.red,
color: e['integrationStatus'] == 'SUCCESS'
? Colors.green
: e['integrationStatus'] == 'PENDING'
? Colors.orange
: Colors.red,
),
],
),

View File

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

View File

@@ -1,6 +1,7 @@
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/transaction_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
@@ -68,49 +69,55 @@ class _IntegrationScreenState extends State<IntegrationScreen> {
return Container(
padding: EdgeInsets.all(50),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 75),
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\'ve successfully collected your funds. We are now '
'updating your billing account. This won\'t take long',
style: TextStyle(fontSize: 20),
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();
},
Column(
children: [
Text(
'We\'ve successfully collected your funds. We are now '
'updating your billing account. 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 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:go_router/go_router.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/onboarding/login/login_controller.dart';
class CompleteScreen extends StatefulWidget {
@@ -16,56 +17,61 @@ class _CompleteScreenState extends State<CompleteScreen> {
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(25),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 75),
Text(
'Registration Complete',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
SizedBox(height: 5),
Text(
'Thank you for registering with Peak payments',
style: TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
Image.asset('assets/landing.png', width: 300),
Text(
'Tap Login to get started',
style: TextStyle(fontSize: 18),
),
SizedBox(height: 10),
],
),
),
),
bottomNavigationBar: SizedBox(
height: 130,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 25),
child: Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () {
context.go('/onboarding/login');
},
child: Text('Go to Login', style: TextStyle(fontSize: 16)),
),
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toDouble()
: constraints.maxWidth;
return SizedBox(
width: maxWidth,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 75),
Text(
'Registration Complete',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
SizedBox(height: 5),
Text(
'Thank you for registering with Velocity payments',
style: TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
Image.asset('assets/landing.png', width: 300),
Text(
'Tap Login to get started',
style: TextStyle(fontSize: 18),
),
SizedBox(height: 30),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 25),
child: Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () {
context.go('/onboarding/login');
},
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:go_router/go_router.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/onboarding/login/login_controller.dart';
class LandingScreen extends StatefulWidget {
@@ -16,73 +17,82 @@ class _LandingScreenState extends State<LandingScreen> {
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(25),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 75),
Text(
'Welcome to Peak',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
SizedBox(height: 5),
Text(
'The fastest way to pay for local goods and services',
style: TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
Image.asset('assets/landing.png', width: 300),
Image.asset('assets/peak.png', width: 100),
SizedBox(height: 5),
Text(
'Register or login to get started',
style: TextStyle(fontSize: 16),
),
SizedBox(height: 10),
],
),
),
),
bottomNavigationBar: SizedBox(
height: 130,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 25),
child: Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () {
context.go('/onboarding/register');
},
child: Text('Register', style: TextStyle(fontSize: 16)),
),
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toDouble()
: constraints.maxWidth;
return SizedBox(
width: maxWidth,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 75),
Text(
'Welcome to Velocity',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
SizedBox(height: 5),
Text(
'The fastest way to pay for local goods and services',
style: TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
Image.asset('assets/landing.png', width: 300),
Image.asset('assets/velocity.png', width: 150),
SizedBox(height: 5),
Text(
'Register or login to get started',
style: TextStyle(fontSize: 16),
),
SizedBox(height: 50),
Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 25),
child: Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () {
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:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:go_router/go_router.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:shared_preferences/shared_preferences.dart';
import 'package:skeletonizer/skeletonizer.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@@ -17,6 +21,8 @@ class LoginScreen extends StatefulWidget {
class _LoginScreenState extends State<LoginScreen> {
late SharedPreferences prefs;
late GoogleSignIn googleSignIn;
final _formKey = GlobalKey<FormState>();
bool _loading = false;
@@ -30,22 +36,23 @@ class _LoginScreenState extends State<LoginScreen> {
void initState() {
super.initState();
_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 {
final GoogleSignIn googleSignIn = GoogleSignIn.instance;
unawaited(googleSignIn.initialize(serverClientId: dotenv.env['CLIENTID']));
if (GoogleSignIn.instance.supportsAuthenticate()){
unawaited(GoogleSignIn.instance.authenticate().then((value) async {
print(value);
saveUser(value);
await _loginController.login(value.email, value.id);
context.go('/');
googleSignIn.authenticationEvents
.listen(_handleAuthenticationEvent)
.onError(_handleAuthenticationError);
@@ -85,6 +92,12 @@ class _LoginScreenState extends State<LoginScreen> {
?.authorizationClient
.authorizationForScopes(scopes);
// #enddocregion CheckAuthorization
print(user);
saveUser(user as GoogleSignInAccount);
await _loginController.login(user.email, user.id);
context.go('/');
}
Future<void> _handleAuthenticationError(Object e) async {
@@ -105,100 +118,117 @@ class _LoginScreenState extends State<LoginScreen> {
padding: const EdgeInsets.all(25),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 75),
Text(
'Login',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
SizedBox(height: 5),
Text(
'Welcome back to Peak',
style: TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
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),
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toDouble()
: constraints.maxWidth;
return SizedBox(
width: maxWidth,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 75),
Text(
'Login',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
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: [
SizedBox(height: 5),
TextButton(
);
}
Widget _buildGoogleButton(){
return Skeletonizer(
enabled: _loading,
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
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_contacts/flutter_contacts.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:skeletonizer/skeletonizer.dart';
@@ -82,89 +83,94 @@ class _PhoneScreenState extends State<PhoneScreen> {
padding: const EdgeInsets.all(25.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
SizedBox(height: 75),
Text(
'Verify Your Device',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Theme
.of(context)
.colorScheme
.primary,
),
textAlign: TextAlign.center,
),
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),
],
),
),
),
),
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),
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toDouble()
: constraints.maxWidth;
return SizedBox(
width: maxWidth,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
SizedBox(height: 75),
Text(
'Verify Your Device',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Theme
.of(context)
.colorScheme
.primary,
),
textAlign: TextAlign.center,
),
),
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 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:go_router/go_router.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:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
@@ -17,6 +20,7 @@ class RegisterScreen extends StatefulWidget {
class _RegisterScreenState extends State<RegisterScreen> {
late OnboardingController onboardingController;
late GoogleSignIn googleSignIn;
bool _loading = false;
@@ -32,17 +36,22 @@ class _RegisterScreenState extends State<RegisterScreen> {
listen: false,
);
// 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() {
final GoogleSignIn googleSignIn = GoogleSignIn.instance;
unawaited(googleSignIn.initialize(serverClientId: dotenv.env['CLIENTID']));
if (GoogleSignIn.instance.supportsAuthenticate()){
unawaited(GoogleSignIn.instance.authenticate().then((value) {
print(value);
onboardingController.updateGoogleUser(value);
googleSignIn.authenticationEvents
.listen(_handleAuthenticationEvent)
.onError(_handleAuthenticationError);
@@ -51,9 +60,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
}
}
Future<void> _handleAuthenticationEvent(
GoogleSignInAuthenticationEvent event,
) async {
Future<void> _handleAuthenticationEvent(GoogleSignInAuthenticationEvent event) async {
// #docregion CheckAuthorization
final GoogleSignInAccount? user = // ...
// #enddocregion CheckAuthorization
@@ -68,6 +75,9 @@ class _RegisterScreenState extends State<RegisterScreen> {
?.authorizationClient
.authorizationForScopes(scopes);
// #enddocregion CheckAuthorization
print(user);
onboardingController.updateGoogleUser(user as GoogleSignInAccount);
}
Future<void> _handleAuthenticationError(Object e) async {
@@ -83,170 +93,184 @@ class _RegisterScreenState extends State<RegisterScreen> {
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(25),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 75),
Text(
'Registration',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
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(
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toDouble()
: constraints.maxWidth;
return SizedBox(
width: maxWidth,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
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,
SizedBox(height: 75),
Text(
'Registration',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
Expanded(child: Divider(color: Colors.grey.shade300)),
],
),
SizedBox(height: 20),
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(height: 5),
Text(
"Let's start by getting some of your online details 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: [
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: 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: 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: 40),
SizedBox(height: 10),
TextButton(
onPressed: () {
context.go('/');
},
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
),
],
],
),
);
}
),
),
),
),
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:go_router/go_router.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/onboarding/verify/verify_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
@@ -59,169 +60,162 @@ class _VerifyScreenState extends State<VerifyScreen> {
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(25),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 75),
Text(
'Verify Your Account',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
SizedBox(height: 5),
Text(
'Enter the 6-digit code sent to your phone/email',
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),
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toDouble()
: constraints.maxWidth;
return SizedBox(
width: maxWidth,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 75),
Text(
'Verify Your Account',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
SizedBox(height: 5),
Text(
'Enter the 6-digit code sent to your phone/email',
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
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Didn't receive the code? ",
style: TextStyle(color: Colors.grey.shade600, fontSize: 16),
),
TextButton(
onPressed: () {
verifyController.resendOtp();
},
child: Text(
'Resend Code',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.w600,
SizedBox(height: 5),
if(errorMessage != null)
Text(errorMessage ?? '', style: TextStyle(color: Colors.red)),
SizedBox(height: 30),
// Resend Code Section
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Didn't receive the code? ",
style: TextStyle(color: Colors.grey.shade600, fontSize: 16),
),
TextButton(
onPressed: () {
verifyController.resendOtp();
},
child: Text(
'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',
// style: TextStyle(color: Colors.grey.shade500, fontSize: 14),
// ),
SizedBox(height: 40),
],
),
),
),
bottomNavigationBar: SizedBox(
height: 130,
child: Column(
children: [
Padding(
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;
}
// SizedBox(height: 20),
// // Timer for resend (optional)
// Text(
// 'Resend available in 2:30',
// style: TextStyle(color: Colors.grey.shade500, fontSize: 14),
// ),
SizedBox(height: 40),
Padding(
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) {
showErrorMessage('Invalid code');
return;
}
verifyController.updateCode(code);
await verifyController.verifyOtp();
if(verifyController.model.status == 'done') {
context.go('/onboarding/complete');
}
},
style: ElevatedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
if(code.length != 6) {
showErrorMessage('Invalid code');
return;
}
verifyController.updateCode(code);
await verifyController.verifyOtp();
if(verifyController.model.status == 'done') {
context.go('/onboarding/complete');
}
},
child: Text(
'Verify Code',
style: TextStyle(fontSize: 16),
),
),
),
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(width: 16),
Expanded(
child: OutlinedButton(
SizedBox(height: 10),
TextButton(
onPressed: () {
context.go('/onboarding/login');
context.go('/');
},
style: OutlinedButton.styleFrom(
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),
),
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
),
),
],
),
),
],
),
);
}
),
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(
type: "CONFIRM",
billClientId: model.selectedProvider?.clientId ?? '',
debitRef: 'peak',
debitRef: 'Velocity',
debitCurrency: 'USD',
creditAccount: transactionController.model.formData.creditAccount,
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:go_router/go_router.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/transaction_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
@@ -178,105 +179,127 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
position: _slideAnimation,
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"TRANSACTIONS DETAILS",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 20),
Row(
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: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
controller
.model
.selectedProvider
?.accountFieldName ??
"",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
transactionController
.model
.formData
.creditAccount,
style: TextStyle(fontSize: 16),
),
],
),
const SizedBox(height: 10),
Divider(color: Theme.of(context).colorScheme.primary),
InkWell(
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: () {
setState(() {
showAdditionalRecipientDetails =
!showAdditionalRecipientDetails;
});
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: () {},
icon: Icon(
showAdditionalRecipientDetails
? Icons.arrow_drop_up
: Icons.arrow_drop_down,
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: double.parse(constraints.maxWidth > ResponsivePolicy.md ?
ResponsivePolicy.md.toString() :
constraints.maxWidth.toString()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
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.start,
children: [
Text(
"TRANSACTIONS DETAILS",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 10),
Row(
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: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
controller
.model
.selectedProvider
?.accountFieldName ??
"",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
transactionController
.model
.formData
.creditAccount,
style: TextStyle(fontSize: 16),
),
],
),
],
),
),
color: Theme.of(context).colorScheme.primary,
),
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),
),
],
SizedBox(height: 20),
InkWell(
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: () {
setState(() {
showAdditionalRecipientDetails =
!showAdditionalRecipientDetails;
});
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: () {},
icon: Icon(
showAdditionalRecipientDetails
? Icons.arrow_drop_up
: Icons.arrow_drop_down,
),
color: Theme.of(context).colorScheme.primary,
),
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:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/transaction_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:qpay/screens/recipient/recipients_controller.dart';
@@ -83,81 +84,107 @@ class _RecipientsScreenState extends State<RecipientsScreen>
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"TRANSACTIONS DETAILS",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("Provider", style: TextStyle(fontSize: 16)),
Text(
controller.model.selectedProvider?.name ?? "",
style: TextStyle(fontSize: 16),
),
],
),
const SizedBox(height: 20),
Divider(color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 20),
_buildAccountField(controller),
const SizedBox(height: 20),
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,
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: double.parse(constraints.maxWidth > ResponsivePolicy.md ?
ResponsivePolicy.md.toString() :
constraints.maxWidth.toString()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
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.start,
children: [
Text(
"TRANSACTIONS DETAILS",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
SizedBox(width: 5),
Icon(Icons.arrow_forward_ios, size: 12),
],
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("Provider",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
)),
Text(
controller.model.selectedProvider?.name ?? "",
style: TextStyle(fontSize: 16),
),
],
),
],
),
),
),
)
: SizedBox(),
const SizedBox(height: 20),
Text(
"Recent Recipients",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
_buildRecipientItems(controller),
],
const SizedBox(height: 20),
_buildAccountField(controller),
const SizedBox(height: 20),
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,
),
),
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),
],
),
);
}
),
),
),
);