progress on home page

This commit is contained in:
2025-06-27 17:26:36 +02:00
parent a866d97b70
commit 03d0f77ac2
15 changed files with 929 additions and 279 deletions

View File

@@ -1,2 +1,2 @@
; BASE_URL=http://192.168.100.8:6950/api
BASE_URL=http://192.168.1.164:6950/api
BASE_URL=http://192.168.100.8:6950/api
; BASE_URL=http://192.168.1.164:6950/api

BIN
assets/card-background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

BIN
assets/econet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
assets/netone.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
assets/zesa.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View File

@@ -15,8 +15,8 @@ class Http {
dio.options.receiveTimeout = const Duration(seconds: 60);
dio.interceptors.add(LogInterceptor(
request: true,
responseBody: true,
requestBody: true,
responseBody: false,
requestBody: false,
error: true,
));
}

View File

@@ -1,20 +1,25 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'providers/transaction_provider.dart';
import 'screens/home/home_screen.dart';
import 'screens/make_payment_screen.dart';
import 'screens/pay/pay_screen.dart';
import 'screens/history_screen.dart';
import 'screens/profile_screen.dart';
import 'theme/app_theme.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:qpay/screens/pay/pay_controller.dart';
const String testEnv = "assets/.env";
const String liveEnv = "assets/.env";
void main() async {
await dotenv.load(fileName: testEnv);
runApp(const MyApp());
runApp(
ChangeNotifierProvider(
create: (context) => PayController(),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
@@ -39,7 +44,7 @@ class MyApp extends StatelessWidget {
),
GoRoute(
path: '/make-payment',
builder: (context, state) => const MakePaymentScreen(),
builder: (context, state) => const PayScreen(),
),
GoRoute(
path: '/history',

View File

@@ -10,6 +10,10 @@ part 'home_controller.g.dart';
class HomeScreenModel {
List<dynamic> transactions = [];
List<Category> categories = [];
List<BillProvider> providers = [];
List<BillProvider> filterableProviders = [];
BillProvider? selectedProvider;
dynamic selectedCategory;
dynamic account = {};
dynamic profile = {};
bool isLoading = false;
@@ -30,13 +34,64 @@ abstract class Category with _$Category {
_$CategoryFromJson(json);
}
var logger = Logger(printer: PrettyPrinter());
@freezed
abstract class BillProvider with _$BillProvider {
const factory BillProvider({
required String clientId,
required String name,
required String description,
required bool requiresAccount,
required bool requiresAmount,
required bool requiresAmountFromMerchant,
required bool requiresPhone,
required bool requiresReversal,
required String? additionalDataString,
required String processorType,
required String uid,
required String image,
required String label,
required String category,
}) = _BillProvider;
factory BillProvider.fromJson(Map<String, dynamic> json) =>
_$BillProviderFromJson(json);
}
class HomeController extends ChangeNotifier {
final HomeScreenModel model = HomeScreenModel();
final Http http = Http();
Future<void> getProviders() async {
model.providers = getFakeProviders();
try {
model.isLoading = true;
List<dynamic> response = await http.get('/providers');
model.providers = response.map((e) => BillProvider.fromJson(e)).toList();
model.filterableProviders = model.providers;
if (model.providers.isNotEmpty) {
updateSelectedProvider(model.providers[0]);
}
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
model.providers = [];
model.filterableProviders = [];
}
model.isLoading = false;
notifyListeners();
}
void updateSelectedProvider(BillProvider provider) {
model.selectedProvider = provider;
// getProducts(provider.uid);
notifyListeners();
}
Future<void> getCategories() async {
// activate skeletonizer
model.categories = getFakeCategoryData();
@@ -45,16 +100,58 @@ class HomeController extends ChangeNotifier {
model.isLoading = true;
List<dynamic> response = await http.get('/categories');
model.categories = response.map((e) => Category.fromJson(e)).toList();
} on DioException catch (e) {
if (model.categories.isNotEmpty) {
updateSelectedCategory(model.categories[0]);
}
} on Exception catch (e) {
logger.e(e);
model.errorMessage = e.type.name;
model.errorMessage = e.toString();
model.categories = [];
} finally {
model.isLoading = false;
notifyListeners();
}
}
Future<void> updateSelectedCategory(Category? category) async {
if (category == null) {
model.filterableProviders = model.providers;
notifyListeners();
return;
}
model.isLoading = false;
model.selectedCategory = category;
model.filterableProviders =
model.providers
.where((element) => element.category == category.label)
.toList();
notifyListeners();
}
List<BillProvider> getFakeProviders() {
return [
BillProvider(
clientId: '1',
name: 'ZESA',
description: 'Pay your electricity bills easily',
requiresAccount: false,
requiresAmount: false,
requiresAmountFromMerchant: false,
requiresPhone: false,
requiresReversal: false,
additionalDataString: '',
processorType: '',
uid: '',
image: 'econet.png',
label: '',
category: '',
),
];
}
// required by skeletonizer
List<Category> getFakeCategoryData() {
return [

View File

@@ -152,6 +152,178 @@ as String,
}
}
/// @nodoc
mixin _$BillProvider {
String get clientId; String get name; String get description; bool get requiresAccount; bool get requiresAmount; bool get requiresAmountFromMerchant; bool get requiresPhone; bool get requiresReversal; String? get additionalDataString; String get processorType; String get uid; String get image; String get label; String get category;
/// Create a copy of BillProvider
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BillProviderCopyWith<BillProvider> get copyWith => _$BillProviderCopyWithImpl<BillProvider>(this as BillProvider, _$identity);
/// Serializes this BillProvider to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BillProvider&&(identical(other.clientId, clientId) || other.clientId == clientId)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.requiresAccount, requiresAccount) || other.requiresAccount == requiresAccount)&&(identical(other.requiresAmount, requiresAmount) || other.requiresAmount == requiresAmount)&&(identical(other.requiresAmountFromMerchant, requiresAmountFromMerchant) || other.requiresAmountFromMerchant == requiresAmountFromMerchant)&&(identical(other.requiresPhone, requiresPhone) || other.requiresPhone == requiresPhone)&&(identical(other.requiresReversal, requiresReversal) || other.requiresReversal == requiresReversal)&&(identical(other.additionalDataString, additionalDataString) || other.additionalDataString == additionalDataString)&&(identical(other.processorType, processorType) || other.processorType == processorType)&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.image, image) || other.image == image)&&(identical(other.label, label) || other.label == label)&&(identical(other.category, category) || other.category == category));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,clientId,name,description,requiresAccount,requiresAmount,requiresAmountFromMerchant,requiresPhone,requiresReversal,additionalDataString,processorType,uid,image,label,category);
@override
String toString() {
return 'BillProvider(clientId: $clientId, name: $name, description: $description, requiresAccount: $requiresAccount, requiresAmount: $requiresAmount, requiresAmountFromMerchant: $requiresAmountFromMerchant, requiresPhone: $requiresPhone, requiresReversal: $requiresReversal, additionalDataString: $additionalDataString, processorType: $processorType, uid: $uid, image: $image, label: $label, category: $category)';
}
}
/// @nodoc
abstract mixin class $BillProviderCopyWith<$Res> {
factory $BillProviderCopyWith(BillProvider value, $Res Function(BillProvider) _then) = _$BillProviderCopyWithImpl;
@useResult
$Res call({
String clientId, String name, String description, bool requiresAccount, bool requiresAmount, bool requiresAmountFromMerchant, bool requiresPhone, bool requiresReversal, String? additionalDataString, String processorType, String uid, String image, String label, String category
});
}
/// @nodoc
class _$BillProviderCopyWithImpl<$Res>
implements $BillProviderCopyWith<$Res> {
_$BillProviderCopyWithImpl(this._self, this._then);
final BillProvider _self;
final $Res Function(BillProvider) _then;
/// Create a copy of BillProvider
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? clientId = null,Object? name = null,Object? description = null,Object? requiresAccount = null,Object? requiresAmount = null,Object? requiresAmountFromMerchant = null,Object? requiresPhone = null,Object? requiresReversal = null,Object? additionalDataString = freezed,Object? processorType = null,Object? uid = null,Object? image = null,Object? label = null,Object? category = null,}) {
return _then(_self.copyWith(
clientId: null == clientId ? _self.clientId : clientId // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String,requiresAccount: null == requiresAccount ? _self.requiresAccount : requiresAccount // ignore: cast_nullable_to_non_nullable
as bool,requiresAmount: null == requiresAmount ? _self.requiresAmount : requiresAmount // ignore: cast_nullable_to_non_nullable
as bool,requiresAmountFromMerchant: null == requiresAmountFromMerchant ? _self.requiresAmountFromMerchant : requiresAmountFromMerchant // ignore: cast_nullable_to_non_nullable
as bool,requiresPhone: null == requiresPhone ? _self.requiresPhone : requiresPhone // ignore: cast_nullable_to_non_nullable
as bool,requiresReversal: null == requiresReversal ? _self.requiresReversal : requiresReversal // ignore: cast_nullable_to_non_nullable
as bool,additionalDataString: freezed == additionalDataString ? _self.additionalDataString : additionalDataString // ignore: cast_nullable_to_non_nullable
as String?,processorType: null == processorType ? _self.processorType : processorType // ignore: cast_nullable_to_non_nullable
as String,uid: null == uid ? _self.uid : uid // ignore: cast_nullable_to_non_nullable
as String,image: null == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
as String,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
as String,category: null == category ? _self.category : category // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
@JsonSerializable()
class _BillProvider implements BillProvider {
const _BillProvider({required this.clientId, required this.name, required this.description, required this.requiresAccount, required this.requiresAmount, required this.requiresAmountFromMerchant, required this.requiresPhone, required this.requiresReversal, required this.additionalDataString, required this.processorType, required this.uid, required this.image, required this.label, required this.category});
factory _BillProvider.fromJson(Map<String, dynamic> json) => _$BillProviderFromJson(json);
@override final String clientId;
@override final String name;
@override final String description;
@override final bool requiresAccount;
@override final bool requiresAmount;
@override final bool requiresAmountFromMerchant;
@override final bool requiresPhone;
@override final bool requiresReversal;
@override final String? additionalDataString;
@override final String processorType;
@override final String uid;
@override final String image;
@override final String label;
@override final String category;
/// Create a copy of BillProvider
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$BillProviderCopyWith<_BillProvider> get copyWith => __$BillProviderCopyWithImpl<_BillProvider>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$BillProviderToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _BillProvider&&(identical(other.clientId, clientId) || other.clientId == clientId)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.requiresAccount, requiresAccount) || other.requiresAccount == requiresAccount)&&(identical(other.requiresAmount, requiresAmount) || other.requiresAmount == requiresAmount)&&(identical(other.requiresAmountFromMerchant, requiresAmountFromMerchant) || other.requiresAmountFromMerchant == requiresAmountFromMerchant)&&(identical(other.requiresPhone, requiresPhone) || other.requiresPhone == requiresPhone)&&(identical(other.requiresReversal, requiresReversal) || other.requiresReversal == requiresReversal)&&(identical(other.additionalDataString, additionalDataString) || other.additionalDataString == additionalDataString)&&(identical(other.processorType, processorType) || other.processorType == processorType)&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.image, image) || other.image == image)&&(identical(other.label, label) || other.label == label)&&(identical(other.category, category) || other.category == category));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,clientId,name,description,requiresAccount,requiresAmount,requiresAmountFromMerchant,requiresPhone,requiresReversal,additionalDataString,processorType,uid,image,label,category);
@override
String toString() {
return 'BillProvider(clientId: $clientId, name: $name, description: $description, requiresAccount: $requiresAccount, requiresAmount: $requiresAmount, requiresAmountFromMerchant: $requiresAmountFromMerchant, requiresPhone: $requiresPhone, requiresReversal: $requiresReversal, additionalDataString: $additionalDataString, processorType: $processorType, uid: $uid, image: $image, label: $label, category: $category)';
}
}
/// @nodoc
abstract mixin class _$BillProviderCopyWith<$Res> implements $BillProviderCopyWith<$Res> {
factory _$BillProviderCopyWith(_BillProvider value, $Res Function(_BillProvider) _then) = __$BillProviderCopyWithImpl;
@override @useResult
$Res call({
String clientId, String name, String description, bool requiresAccount, bool requiresAmount, bool requiresAmountFromMerchant, bool requiresPhone, bool requiresReversal, String? additionalDataString, String processorType, String uid, String image, String label, String category
});
}
/// @nodoc
class __$BillProviderCopyWithImpl<$Res>
implements _$BillProviderCopyWith<$Res> {
__$BillProviderCopyWithImpl(this._self, this._then);
final _BillProvider _self;
final $Res Function(_BillProvider) _then;
/// Create a copy of BillProvider
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? clientId = null,Object? name = null,Object? description = null,Object? requiresAccount = null,Object? requiresAmount = null,Object? requiresAmountFromMerchant = null,Object? requiresPhone = null,Object? requiresReversal = null,Object? additionalDataString = freezed,Object? processorType = null,Object? uid = null,Object? image = null,Object? label = null,Object? category = null,}) {
return _then(_BillProvider(
clientId: null == clientId ? _self.clientId : clientId // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String,requiresAccount: null == requiresAccount ? _self.requiresAccount : requiresAccount // ignore: cast_nullable_to_non_nullable
as bool,requiresAmount: null == requiresAmount ? _self.requiresAmount : requiresAmount // ignore: cast_nullable_to_non_nullable
as bool,requiresAmountFromMerchant: null == requiresAmountFromMerchant ? _self.requiresAmountFromMerchant : requiresAmountFromMerchant // ignore: cast_nullable_to_non_nullable
as bool,requiresPhone: null == requiresPhone ? _self.requiresPhone : requiresPhone // ignore: cast_nullable_to_non_nullable
as bool,requiresReversal: null == requiresReversal ? _self.requiresReversal : requiresReversal // ignore: cast_nullable_to_non_nullable
as bool,additionalDataString: freezed == additionalDataString ? _self.additionalDataString : additionalDataString // ignore: cast_nullable_to_non_nullable
as String?,processorType: null == processorType ? _self.processorType : processorType // ignore: cast_nullable_to_non_nullable
as String,uid: null == uid ? _self.uid : uid // ignore: cast_nullable_to_non_nullable
as String,image: null == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
as String,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
as String,category: null == category ? _self.category : category // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on

View File

@@ -19,3 +19,39 @@ Map<String, dynamic> _$CategoryToJson(_Category instance) => <String, dynamic>{
'image': instance.image,
'label': instance.label,
};
_BillProvider _$BillProviderFromJson(Map<String, dynamic> json) =>
_BillProvider(
clientId: json['clientId'] as String,
name: json['name'] as String,
description: json['description'] as String,
requiresAccount: json['requiresAccount'] as bool,
requiresAmount: json['requiresAmount'] as bool,
requiresAmountFromMerchant: json['requiresAmountFromMerchant'] as bool,
requiresPhone: json['requiresPhone'] as bool,
requiresReversal: json['requiresReversal'] as bool,
additionalDataString: json['additionalDataString'] as String?,
processorType: json['processorType'] as String,
uid: json['uid'] as String,
image: json['image'] as String,
label: json['label'] as String,
category: json['category'] as String,
);
Map<String, dynamic> _$BillProviderToJson(_BillProvider instance) =>
<String, dynamic>{
'clientId': instance.clientId,
'name': instance.name,
'description': instance.description,
'requiresAccount': instance.requiresAccount,
'requiresAmount': instance.requiresAmount,
'requiresAmountFromMerchant': instance.requiresAmountFromMerchant,
'requiresPhone': instance.requiresPhone,
'requiresReversal': instance.requiresReversal,
'additionalDataString': instance.additionalDataString,
'processorType': instance.processorType,
'uid': instance.uid,
'image': instance.image,
'label': instance.label,
'category': instance.category,
};

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:qpay/screens/home/home_controller.dart';
import 'package:qpay/screens/pay/pay_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'dart:async';
@@ -14,15 +16,15 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late final Animation<AlignmentGeometry> _alignAnimation;
late Animation<AlignmentGeometry> _alignAnimation;
final List<Animation<Offset>> _animations = [];
final List<Animation<Offset>> _alignListAnimations = [];
late final bool _enabled = true;
late bool _loading = true;
Timer? _loadingTimer;
bool get _isLoggedIn => true;
String _selectedFilter = 'All';
final HomeController homeController = HomeController();
@@ -31,6 +33,8 @@ class _HomeScreenState extends State<HomeScreen>
super.initState();
homeController.getCategories();
homeController.getProviders();
_controller = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
@@ -75,6 +79,35 @@ class _HomeScreenState extends State<HomeScreen>
});
}
getBoxDecoration(String? clientId) {
// BillProvider? provider = homeController.model.selectedProvider;
//
// if (provider != null && clientId != provider.clientId) {
// return BoxDecoration(
// border: Border.all(color: Colors.black87.withAlpha(20)),
// borderRadius: BorderRadius.circular(12),
// );
// }
return BoxDecoration(
// border: Border.all(
// color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.7),
// width: 3,
// ),
border: Border.all(color: Colors.black87.withAlpha(20)),
borderRadius: BorderRadius.circular(12),
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
Theme.of(context).colorScheme.tertiary.withValues(alpha: 0.05),
],
stops: const [0.3, 0.7],
begin: Alignment.topRight,
end: Alignment.bottomLeft,
),
);
}
void _showErrorSnackBar(String? message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -84,14 +117,6 @@ class _HomeScreenState extends State<HomeScreen>
);
}
int _getCategoryIndex(String name) {
return homeController.model.categories.indexWhere((e) => e.name == name);
}
bool _isCategoryOdd(String name) {
return _getCategoryIndex(name) % 2 != 0;
}
@override
void dispose() {
_loadingTimer?.cancel();
@@ -101,7 +126,6 @@ class _HomeScreenState extends State<HomeScreen>
@override
Widget build(BuildContext context) {
final VoidCallback? onPressed = _enabled ? () {} : null;
return Scaffold(
body: SafeArea(
child: CustomScrollView(
@@ -119,204 +143,165 @@ class _HomeScreenState extends State<HomeScreen>
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_isLoggedIn)
AlignTransition(
alignment: _alignAnimation,
child: Skeletonizer(
enabled: _loading,
child: Column(
children: [
Text(
"Vusumuzi Khoza",
style: TextStyle(fontSize: 15),
child: ListenableBuilder(
listenable: homeController,
builder: (context, child) {
if (homeController.model.errorMessage != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_showErrorSnackBar(homeController.model.errorMessage);
});
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_isLoggedIn)
AlignTransition(
alignment: _alignAnimation,
child: Skeletonizer(
enabled: _loading,
child: Column(
children: [
Text(
"Vusumuzi Khoza",
style: TextStyle(fontSize: 15),
),
Text(
"USD 4.02",
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 20),
],
),
Text(
"USD 4.02",
),
),
_buildFilterChips(homeController),
SizedBox(height: 10),
Column(
children: [
...homeController.model.filterableProviders.map(
(e) => _buildProviderItem(e, context),
),
],
),
SizedBox(height: 5),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Recent Activity',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
GestureDetector(
onTap: () {
context.go('/history');
},
child: const Text(
'View All',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
fontSize: 16,
fontWeight: FontWeight.normal,
),
),
const SizedBox(height: 20),
],
),
),
],
),
),
ListenableBuilder(
listenable: homeController,
builder: (context, child) {
if (homeController.model.errorMessage != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_showErrorSnackBar(
homeController.model.errorMessage,
);
});
}
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
...homeController.model.categories.map(
(e) => Skeletonizer(
enabled: homeController.model.isLoading,
child: SlideTransition(
position: _animations[0],
child: FilledButton(
style: ButtonStyle(
backgroundColor:
WidgetStateProperty.all(
_isCategoryOdd(e.name)
? Theme.of(context)
.colorScheme
.secondary
.withValues(alpha: 0.9)
: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.9),
),
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(
vertical: 17.5,
horizontal: 30,
),
),
),
onPressed: onPressed,
child: Row(
children: [
Icon(
Icons.electric_bolt,
color: Colors.white,
),
const SizedBox(width: 8),
Text(
e.name,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(height: 20),
Skeletonizer(
enabled: _loading,
child: ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
SlideTransition(
position: _alignListAnimations[0],
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.electric_bolt,
color:
Theme.of(context).colorScheme.primary,
),
),
title: Text(
'Electricity Bill',
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
'Paid USD 50.00',
style: TextStyle(
fontWeight: FontWeight.normal,
),
),
trailing: Text(
'-USD 50.00',
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
),
),
],
),
homeController.model.categories.isNotEmpty
? const SizedBox(height: 30)
: SizedBox(),
],
);
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Recent Activity',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
GestureDetector(
onTap: () {
context.go('/history');
},
child: const Text(
'View All',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.normal,
),
),
SlideTransition(
position: _alignListAnimations[1],
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.phone_android,
color:
Theme.of(context).colorScheme.primary,
),
),
title: Text(
'Airtime Purchase',
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
'Purchased USD 10.00',
style: TextStyle(
fontWeight: FontWeight.normal,
),
),
trailing: Text(
'-USD 10.00',
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
],
),
const SizedBox(height: 20),
Skeletonizer(
enabled: _loading,
child: ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
SlideTransition(
position: _alignListAnimations[0],
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.electric_bolt,
color: Theme.of(context).colorScheme.primary,
),
),
title: Text(
'Electricity Bill',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'Paid USD 50.00',
style: TextStyle(fontWeight: FontWeight.normal),
),
trailing: Text(
'-USD 50.00',
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
),
),
),
SlideTransition(
position: _alignListAnimations[1],
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.phone_android,
color: Theme.of(context).colorScheme.primary,
),
),
title: Text(
'Airtime Purchase',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'Purchased USD 10.00',
style: TextStyle(fontWeight: FontWeight.normal),
),
trailing: Text(
'-USD 10.00',
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
],
);
},
),
),
),
@@ -325,4 +310,104 @@ class _HomeScreenState extends State<HomeScreen>
),
);
}
Column _buildProviderItem(BillProvider e, BuildContext context) {
return Column(
children: [
Skeletonizer(
enabled: homeController.model.isLoading,
child: SlideTransition(
position: _alignListAnimations[1],
child: Container(
decoration: getBoxDecoration(e.clientId),
child: InkWell(
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: () {
homeController.updateSelectedProvider(e);
context.go("/make-payment");
},
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Image(image: AssetImage("assets/${e.image}")),
),
title: Text(
e.name,
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
e.description,
style: TextStyle(fontWeight: FontWeight.normal, fontSize: 10),
),
trailing: Icon(
Icons.chevron_right,
color: Theme.of(
context,
).colorScheme.secondary.withValues(alpha: 0.7),
),
),
),
),
),
),
SizedBox(height: 10),
],
);
}
Widget _buildFilterChips(HomeController controller) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildFilterChip('All', controller),
...controller.model.categories.map((category) {
return _buildFilterChip(category.name, controller, category);
}),
],
),
);
}
Widget _buildFilterChip(
String label,
HomeController controller, [
Category? category,
]) {
final isSelected = _selectedFilter == label;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
selected: isSelected,
label: Text(label),
onSelected: (selected) {
setState(() {
_selectedFilter = label;
});
if (category != null) {
controller.updateSelectedCategory(category);
} else {
controller.updateSelectedCategory(null);
}
},
backgroundColor: Theme.of(context).colorScheme.surface,
selectedColor: Theme.of(context).colorScheme.primary.withOpacity(0.2),
checkmarkColor: Theme.of(context).colorScheme.primary,
labelStyle: TextStyle(
color:
isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
),
);
}
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/screens/home/home_controller.dart' as hc;
part 'pay_controller.freezed.dart';
part 'pay_controller.g.dart';
class PayScreenModel {
List<BillProduct> products = [];
late BillProduct selectedProduct;
dynamic formData = {};
bool isLoading = false;
String? errorMessage;
}
@freezed
abstract class BillProduct with _$BillProduct {
const factory BillProduct({
required String uid,
required String name,
required String description,
required String displayName,
required double defaultAmount,
required bool requiresAmount,
}) = _BillProduct;
factory BillProduct.fromJson(Map<String, dynamic> json) =>
_$BillProductFromJson(json);
}
class PayController extends ChangeNotifier {
final PayScreenModel model = PayScreenModel();
final Http http = Http();
void updateSelectedProduct(BillProduct product) {
model.selectedProduct = product;
notifyListeners();
}
Future<void> getProducts(String providerId) async {
try {
model.isLoading = true;
List<dynamic> response = await http.get(
'/providers/$providerId/products',
);
model.products = response.map((e) => BillProduct.fromJson(e)).toList();
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
}
model.isLoading = false;
notifyListeners();
}
}

View File

@@ -0,0 +1,163 @@
// 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 'pay_controller.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$BillProduct {
String get uid; String get name; String get description; String get displayName; double get defaultAmount; bool get requiresAmount;
/// Create a copy of BillProduct
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BillProductCopyWith<BillProduct> get copyWith => _$BillProductCopyWithImpl<BillProduct>(this as BillProduct, _$identity);
/// Serializes this BillProduct to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BillProduct&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.defaultAmount, defaultAmount) || other.defaultAmount == defaultAmount)&&(identical(other.requiresAmount, requiresAmount) || other.requiresAmount == requiresAmount));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,uid,name,description,displayName,defaultAmount,requiresAmount);
@override
String toString() {
return 'BillProduct(uid: $uid, name: $name, description: $description, displayName: $displayName, defaultAmount: $defaultAmount, requiresAmount: $requiresAmount)';
}
}
/// @nodoc
abstract mixin class $BillProductCopyWith<$Res> {
factory $BillProductCopyWith(BillProduct value, $Res Function(BillProduct) _then) = _$BillProductCopyWithImpl;
@useResult
$Res call({
String uid, String name, String description, String displayName, double defaultAmount, bool requiresAmount
});
}
/// @nodoc
class _$BillProductCopyWithImpl<$Res>
implements $BillProductCopyWith<$Res> {
_$BillProductCopyWithImpl(this._self, this._then);
final BillProduct _self;
final $Res Function(BillProduct) _then;
/// Create a copy of BillProduct
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? uid = null,Object? name = null,Object? description = null,Object? displayName = null,Object? defaultAmount = null,Object? requiresAmount = null,}) {
return _then(_self.copyWith(
uid: null == uid ? _self.uid : uid // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
as String,defaultAmount: null == defaultAmount ? _self.defaultAmount : defaultAmount // ignore: cast_nullable_to_non_nullable
as double,requiresAmount: null == requiresAmount ? _self.requiresAmount : requiresAmount // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// @nodoc
@JsonSerializable()
class _BillProduct implements BillProduct {
const _BillProduct({required this.uid, required this.name, required this.description, required this.displayName, required this.defaultAmount, required this.requiresAmount});
factory _BillProduct.fromJson(Map<String, dynamic> json) => _$BillProductFromJson(json);
@override final String uid;
@override final String name;
@override final String description;
@override final String displayName;
@override final double defaultAmount;
@override final bool requiresAmount;
/// Create a copy of BillProduct
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$BillProductCopyWith<_BillProduct> get copyWith => __$BillProductCopyWithImpl<_BillProduct>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$BillProductToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _BillProduct&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.defaultAmount, defaultAmount) || other.defaultAmount == defaultAmount)&&(identical(other.requiresAmount, requiresAmount) || other.requiresAmount == requiresAmount));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,uid,name,description,displayName,defaultAmount,requiresAmount);
@override
String toString() {
return 'BillProduct(uid: $uid, name: $name, description: $description, displayName: $displayName, defaultAmount: $defaultAmount, requiresAmount: $requiresAmount)';
}
}
/// @nodoc
abstract mixin class _$BillProductCopyWith<$Res> implements $BillProductCopyWith<$Res> {
factory _$BillProductCopyWith(_BillProduct value, $Res Function(_BillProduct) _then) = __$BillProductCopyWithImpl;
@override @useResult
$Res call({
String uid, String name, String description, String displayName, double defaultAmount, bool requiresAmount
});
}
/// @nodoc
class __$BillProductCopyWithImpl<$Res>
implements _$BillProductCopyWith<$Res> {
__$BillProductCopyWithImpl(this._self, this._then);
final _BillProduct _self;
final $Res Function(_BillProduct) _then;
/// Create a copy of BillProduct
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? uid = null,Object? name = null,Object? description = null,Object? displayName = null,Object? defaultAmount = null,Object? requiresAmount = null,}) {
return _then(_BillProduct(
uid: null == uid ? _self.uid : uid // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
as String,defaultAmount: null == defaultAmount ? _self.defaultAmount : defaultAmount // ignore: cast_nullable_to_non_nullable
as double,requiresAmount: null == requiresAmount ? _self.requiresAmount : requiresAmount // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
// dart format on

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'pay_controller.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_BillProduct _$BillProductFromJson(Map<String, dynamic> json) => _BillProduct(
uid: json['uid'] as String,
name: json['name'] as String,
description: json['description'] as String,
displayName: json['displayName'] as String,
defaultAmount: (json['defaultAmount'] as num).toDouble(),
requiresAmount: json['requiresAmount'] as bool,
);
Map<String, dynamic> _$BillProductToJson(_BillProduct instance) =>
<String, dynamic>{
'uid': instance.uid,
'name': instance.name,
'description': instance.description,
'displayName': instance.displayName,
'defaultAmount': instance.defaultAmount,
'requiresAmount': instance.requiresAmount,
};

View File

@@ -1,14 +1,16 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:qpay/screens/pay/pay_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
class MakePaymentScreen extends StatefulWidget {
const MakePaymentScreen({super.key});
class PayScreen extends StatefulWidget {
const PayScreen({super.key});
@override
State<MakePaymentScreen> createState() => _MakePaymentScreenState();
State<PayScreen> createState() => _PayScreenState();
}
class _MakePaymentScreenState extends State<MakePaymentScreen>
with SingleTickerProviderStateMixin {
class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
@@ -16,11 +18,12 @@ class _MakePaymentScreenState extends State<MakePaymentScreen>
final _formKey = GlobalKey<FormState>();
final _amountController = TextEditingController();
final _descriptionController = TextEditingController();
String _selectedBillType = 'Electricity';
String _selectedBillType = '';
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
@@ -54,51 +57,70 @@ class _MakePaymentScreenState extends State<MakePaymentScreen>
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Make Payment'),
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildBillTypeSelector(),
const SizedBox(height: 24),
_buildAmountField(),
const SizedBox(height: 24),
_buildDescriptionField(),
const SizedBox(height: 32),
_buildPayButton(),
],
),
),
),
),
),
void _showErrorSnackBar(String? message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message.toString()),
behavior: SnackBarBehavior.floating,
),
);
}
Widget _buildBillTypeSelector() {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(
'Make Payment',
style: TextStyle(color: Colors.black87),
),
),
body: Consumer<PayController>(
builder: (context, controller, child) {
if (controller.model.errorMessage != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_showErrorSnackBar(controller.model.errorMessage);
});
}
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 20),
_buildBillProductSelector(controller),
const SizedBox(height: 24),
_buildAmountField(),
const SizedBox(height: 24),
_buildDescriptionField(),
const SizedBox(height: 32),
_buildPayButton(),
],
),
),
),
),
),
);
},
),
);
}
Widget _buildBillProductSelector(PayController controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Select Bill Type',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
'Select Provider Product',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 12),
Container(
@@ -112,21 +134,16 @@ class _MakePaymentScreenState extends State<MakePaymentScreen>
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedBillType,
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down),
items: [
'Electricity',
'Water',
'Internet',
'Mobile',
'Gas',
].map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
...controller.model.products.map(
(e) => DropdownMenuItem<String>(
value: e.uid,
child: Text(e.displayName),
),
),
],
onChanged: (String? newValue) {
if (newValue != null) {
setState(() {
@@ -147,10 +164,7 @@ class _MakePaymentScreenState extends State<MakePaymentScreen>
children: [
const Text(
'Amount',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 12),
TextFormField(
@@ -180,10 +194,7 @@ class _MakePaymentScreenState extends State<MakePaymentScreen>
children: [
const Text(
'Description',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 12),
TextFormField(
@@ -218,12 +229,9 @@ class _MakePaymentScreenState extends State<MakePaymentScreen>
),
child: const Text(
'Pay Now',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
);
}
}
}