prototype complete

This commit is contained in:
2025-07-14 01:04:53 +02:00
parent 03d0f77ac2
commit 44d21b3f14
69 changed files with 5033 additions and 765 deletions

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/screens/home/home_controller.dart' as hc;
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:qpay/screens/transaction_controller.dart';
part 'recipients_controller.freezed.dart';
part 'recipients_controller.g.dart';
class RecipientModel {
String? creditAccount;
String? errorMessage;
hc.BillProvider? selectedProvider;
bool isLoading = false;
List<Recipient> recipients = [];
}
@freezed
abstract class Recipient with _$Recipient {
const factory Recipient({
String? uid,
String? name,
String? email,
String? phoneNumber,
String? address,
String? account,
String? initials,
String? latestProviderLabel,
}) = _Recipient;
factory Recipient.fromJson(Map<String, dynamic> json) =>
_$RecipientFromJson(json);
}
class RecipientsController extends ChangeNotifier {
final RecipientModel model = RecipientModel();
final http = Http();
BuildContext context;
RecipientsController(this.context) {
model.selectedProvider =
Provider.of<TransactionController>(
context,
listen: false,
).model.selectedProvider;
}
void _showErrorSnackBar(String? message) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message.toString()),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.deepOrange,
),
);
});
}
Future<void> getRecipients([Map<String, String>? params]) async {
model.errorMessage = null;
model.isLoading = true;
model.recipients = getDummyRecipients();
notifyListeners();
try {
List<dynamic> response = await http.get(
'/recipients/search?${buildQueryParameters(params ?? {})}',
);
model.recipients.clear();
model.recipients.addAll(response.map((e) => Recipient.fromJson(e)));
} catch (e) {
_showErrorSnackBar(
"Problem fetching recipients, are you connected to the internet?",
);
model.errorMessage = e.toString();
model.recipients = [];
logger.e(e);
notifyListeners();
}
model.isLoading = false;
notifyListeners();
}
String buildQueryParameters(Map<String, String> params) {
if (params.isEmpty) {
return '';
}
String query = '';
params.forEach((key, value) {
query += '$key=$value&';
});
return query.substring(0, query.length - 1);
}
void updateCreditAccount(String account) {
model.creditAccount = account;
notifyListeners();
}
void addRecipient(Recipient recipient) {
model.recipients.add(recipient);
notifyListeners();
}
void removeRecipient(String uid) {
model.recipients.removeWhere((recipient) => recipient.uid == uid);
notifyListeners();
}
List<Recipient> getDummyRecipients() {
return [
Recipient(
uid: '1',
name: 'Vusumuzi Khoza',
phoneNumber: '+263773591219',
email: 'vusumuzi@gmail.com',
address: '123 Main St, Anytown, USA',
account: '1234567890',
initials: 'VK',
latestProviderLabel: 'MTN',
),
];
}
}

View File

@@ -0,0 +1,169 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'recipients_controller.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$Recipient {
String? get uid; String? get name; String? get email; String? get phoneNumber; String? get address; String? get account; String? get initials; String? get latestProviderLabel;
/// Create a copy of Recipient
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$RecipientCopyWith<Recipient> get copyWith => _$RecipientCopyWithImpl<Recipient>(this as Recipient, _$identity);
/// Serializes this Recipient to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is Recipient&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.name, name) || other.name == name)&&(identical(other.email, email) || other.email == email)&&(identical(other.phoneNumber, phoneNumber) || other.phoneNumber == phoneNumber)&&(identical(other.address, address) || other.address == address)&&(identical(other.account, account) || other.account == account)&&(identical(other.initials, initials) || other.initials == initials)&&(identical(other.latestProviderLabel, latestProviderLabel) || other.latestProviderLabel == latestProviderLabel));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,uid,name,email,phoneNumber,address,account,initials,latestProviderLabel);
@override
String toString() {
return 'Recipient(uid: $uid, name: $name, email: $email, phoneNumber: $phoneNumber, address: $address, account: $account, initials: $initials, latestProviderLabel: $latestProviderLabel)';
}
}
/// @nodoc
abstract mixin class $RecipientCopyWith<$Res> {
factory $RecipientCopyWith(Recipient value, $Res Function(Recipient) _then) = _$RecipientCopyWithImpl;
@useResult
$Res call({
String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel
});
}
/// @nodoc
class _$RecipientCopyWithImpl<$Res>
implements $RecipientCopyWith<$Res> {
_$RecipientCopyWithImpl(this._self, this._then);
final Recipient _self;
final $Res Function(Recipient) _then;
/// Create a copy of Recipient
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? uid = freezed,Object? name = freezed,Object? email = freezed,Object? phoneNumber = freezed,Object? address = freezed,Object? account = freezed,Object? initials = freezed,Object? latestProviderLabel = freezed,}) {
return _then(_self.copyWith(
uid: freezed == uid ? _self.uid : uid // ignore: cast_nullable_to_non_nullable
as String?,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String?,email: freezed == email ? _self.email : email // ignore: cast_nullable_to_non_nullable
as String?,phoneNumber: freezed == phoneNumber ? _self.phoneNumber : phoneNumber // ignore: cast_nullable_to_non_nullable
as String?,address: freezed == address ? _self.address : address // ignore: cast_nullable_to_non_nullable
as String?,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as String?,initials: freezed == initials ? _self.initials : initials // ignore: cast_nullable_to_non_nullable
as String?,latestProviderLabel: freezed == latestProviderLabel ? _self.latestProviderLabel : latestProviderLabel // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// @nodoc
@JsonSerializable()
class _Recipient implements Recipient {
const _Recipient({this.uid, this.name, this.email, this.phoneNumber, this.address, this.account, this.initials, this.latestProviderLabel});
factory _Recipient.fromJson(Map<String, dynamic> json) => _$RecipientFromJson(json);
@override final String? uid;
@override final String? name;
@override final String? email;
@override final String? phoneNumber;
@override final String? address;
@override final String? account;
@override final String? initials;
@override final String? latestProviderLabel;
/// Create a copy of Recipient
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$RecipientCopyWith<_Recipient> get copyWith => __$RecipientCopyWithImpl<_Recipient>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$RecipientToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Recipient&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.name, name) || other.name == name)&&(identical(other.email, email) || other.email == email)&&(identical(other.phoneNumber, phoneNumber) || other.phoneNumber == phoneNumber)&&(identical(other.address, address) || other.address == address)&&(identical(other.account, account) || other.account == account)&&(identical(other.initials, initials) || other.initials == initials)&&(identical(other.latestProviderLabel, latestProviderLabel) || other.latestProviderLabel == latestProviderLabel));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,uid,name,email,phoneNumber,address,account,initials,latestProviderLabel);
@override
String toString() {
return 'Recipient(uid: $uid, name: $name, email: $email, phoneNumber: $phoneNumber, address: $address, account: $account, initials: $initials, latestProviderLabel: $latestProviderLabel)';
}
}
/// @nodoc
abstract mixin class _$RecipientCopyWith<$Res> implements $RecipientCopyWith<$Res> {
factory _$RecipientCopyWith(_Recipient value, $Res Function(_Recipient) _then) = __$RecipientCopyWithImpl;
@override @useResult
$Res call({
String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel
});
}
/// @nodoc
class __$RecipientCopyWithImpl<$Res>
implements _$RecipientCopyWith<$Res> {
__$RecipientCopyWithImpl(this._self, this._then);
final _Recipient _self;
final $Res Function(_Recipient) _then;
/// Create a copy of Recipient
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? uid = freezed,Object? name = freezed,Object? email = freezed,Object? phoneNumber = freezed,Object? address = freezed,Object? account = freezed,Object? initials = freezed,Object? latestProviderLabel = freezed,}) {
return _then(_Recipient(
uid: freezed == uid ? _self.uid : uid // ignore: cast_nullable_to_non_nullable
as String?,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String?,email: freezed == email ? _self.email : email // ignore: cast_nullable_to_non_nullable
as String?,phoneNumber: freezed == phoneNumber ? _self.phoneNumber : phoneNumber // ignore: cast_nullable_to_non_nullable
as String?,address: freezed == address ? _self.address : address // ignore: cast_nullable_to_non_nullable
as String?,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as String?,initials: freezed == initials ? _self.initials : initials // ignore: cast_nullable_to_non_nullable
as String?,latestProviderLabel: freezed == latestProviderLabel ? _self.latestProviderLabel : latestProviderLabel // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
// dart format on

View File

@@ -0,0 +1,30 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'recipients_controller.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_Recipient _$RecipientFromJson(Map<String, dynamic> json) => _Recipient(
uid: json['uid'] as String?,
name: json['name'] as String?,
email: json['email'] as String?,
phoneNumber: json['phoneNumber'] as String?,
address: json['address'] as String?,
account: json['account'] as String?,
initials: json['initials'] as String?,
latestProviderLabel: json['latestProviderLabel'] as String?,
);
Map<String, dynamic> _$RecipientToJson(_Recipient instance) =>
<String, dynamic>{
'uid': instance.uid,
'name': instance.name,
'email': instance.email,
'phoneNumber': instance.phoneNumber,
'address': instance.address,
'account': instance.account,
'initials': instance.initials,
'latestProviderLabel': instance.latestProviderLabel,
};

View File

@@ -0,0 +1,378 @@
import 'dart:async';
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/screens/transaction_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:qpay/screens/recipient/recipients_controller.dart';
import 'package:shared_preferences/shared_preferences.dart';
class RecipientsScreen extends StatefulWidget {
const RecipientsScreen({super.key});
@override
State<RecipientsScreen> createState() => _RecipientsScreenState();
}
class _RecipientsScreenState extends State<RecipientsScreen>
with TickerProviderStateMixin {
late SharedPreferences prefs;
late AnimationController _animationController;
late RecipientsController controller;
late TransactionController transactionController;
final List<Animation<Offset>> _alignListAnimations = [];
final _accountController = TextEditingController();
final _queryParams = <String, String>{};
@override
void initState() {
super.initState();
controller = RecipientsController(context);
setupData();
controller.model.creditAccount = _accountController.text;
transactionController = Provider.of<TransactionController>(
context,
listen: false,
);
_animationController = AnimationController(
duration: const Duration(milliseconds: 600),
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: _animationController,
curve: Interval(0.075 * i, 1.0, curve: Curves.easeOut),
),
),
);
}
}
Future<void> setupData() async {
prefs = await SharedPreferences.getInstance();
_queryParams['userId'] = prefs.getString("userId")!;
controller.getRecipients(_queryParams);
}
@override
void dispose() {
_animationController.dispose();
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Recipients", style: TextStyle(color: Colors.black87)),
),
body: ListenableBuilder(
listenable: controller,
builder: (context, child) {
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,
),
),
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),
],
),
),
);
},
),
);
}
Widget _buildAccountField(RecipientsController controller) {
String fieldName =
controller.model.selectedProvider?.accountFieldName ?? "Account Number";
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(fieldName, style: TextStyle(fontSize: 16)),
TextButton(
onPressed: () async {
if (await FlutterContacts.requestPermission()) {
final contact = await FlutterContacts.openExternalPick();
if (contact != null) {
_accountController.text = contact.phones.first.number;
controller.updateCreditAccount(contact.phones.first.number);
}
}
},
child: Text(
"Fetch from contacts",
style: TextStyle(fontSize: 12, color: Colors.red),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextFormField(
controller: _accountController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
hintText: 'Enter / Search $fieldName',
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.secondary,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.primary.withOpacity(0.2),
),
),
),
onChanged: (value) {
controller.updateCreditAccount(value);
},
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter an amount';
}
if (double.tryParse(value) == null) {
return 'Please enter a valid amount';
}
return null;
},
),
),
IconButton(
onPressed: () {
_queryParams.clear();
setState(() {
_queryParams['account'] = _accountController.text;
_queryParams['email'] = _accountController.text;
_queryParams['phoneNumber'] = _accountController.text;
_queryParams['name'] = _accountController.text;
_queryParams['userId'] = prefs.getString("userId")!;
controller.getRecipients(_queryParams);
});
},
icon: Icon(
Icons.search,
color: Theme.of(context).colorScheme.primary,
size: 24,
),
),
],
),
],
);
}
Widget _buildRecipientItems(RecipientsController controller) {
if (controller.model.recipients.isEmpty && !controller.model.isLoading) {
return Center(child: Text("No recipients yet"));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...controller.model.recipients.map(
(recipient) => Skeletonizer(
enabled: controller.model.isLoading,
child: InkWell(
onTap: () {
String account = recipient.account ?? '';
if (!transactionController
.model
.selectedProvider!
.requiresAccount) {
account = recipient.phoneNumber ?? '';
}
transactionController
.model
.formData = transactionController.model.formData.copyWith(
creditAccount: account,
creditName: recipient.name ?? '',
creditPhone: recipient.phoneNumber ?? '',
creditEmail: recipient.email ?? '',
providerLabel:
transactionController.model.formData.providerLabel.isEmpty
? recipient.latestProviderLabel ?? ''
: transactionController.model.formData.providerLabel,
);
context.push("/make-payment");
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: EdgeInsets.all(5),
child: SlideTransition(
position: _alignListAnimations[0],
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Text(
recipient.initials ?? 'PK',
style: TextStyle(
fontSize: 25,
fontWeight: FontWeight.bold,
),
),
),
SizedBox(width: 20),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
recipient.latestProviderLabel ?? '',
style: TextStyle(fontWeight: FontWeight.bold),
),
SizedBox(width: 5),
Text(
recipient.account ?? '',
style: TextStyle(fontWeight: FontWeight.normal),
),
],
),
if ((recipient.phoneNumber != null &&
recipient.phoneNumber!.isNotEmpty) ||
(recipient.email != null &&
recipient.email!.isNotEmpty))
Row(
spacing: 5,
children: [
if (recipient.phoneNumber != null &&
recipient.phoneNumber!.isNotEmpty)
Text(
recipient.phoneNumber ?? '',
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
if (recipient.email != null &&
recipient.email!.isNotEmpty)
Text(
recipient.email ?? '',
style: TextStyle(
fontWeight: FontWeight.normal,
),
),
],
),
if (recipient.name != null &&
recipient.name!.isNotEmpty)
Text(
recipient.name ?? '',
style: TextStyle(fontWeight: FontWeight.normal),
),
],
),
],
),
),
),
),
),
),
],
);
}
}