diff --git a/assets/.env b/assets/.env index 0c62941..1546950 100644 --- a/assets/.env +++ b/assets/.env @@ -1,2 +1,2 @@ -; BASE_URL=http://192.168.100.8:6950/api -BASE_URL=http://192.168.1.164:6950/api \ No newline at end of file +BASE_URL=http://192.168.100.8:6950/api +; BASE_URL=http://192.168.1.164:6950/api \ No newline at end of file diff --git a/assets/card-background.png b/assets/card-background.png new file mode 100644 index 0000000..5a94ad7 Binary files /dev/null and b/assets/card-background.png differ diff --git a/assets/econet.png b/assets/econet.png new file mode 100644 index 0000000..eb80806 Binary files /dev/null and b/assets/econet.png differ diff --git a/assets/netone.png b/assets/netone.png new file mode 100644 index 0000000..de07284 Binary files /dev/null and b/assets/netone.png differ diff --git a/assets/zesa.png b/assets/zesa.png new file mode 100644 index 0000000..0da727c Binary files /dev/null and b/assets/zesa.png differ diff --git a/lib/http/http.dart b/lib/http/http.dart index 6cf8da9..e8d4866 100644 --- a/lib/http/http.dart +++ b/lib/http/http.dart @@ -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, )); } diff --git a/lib/main.dart b/lib/main.dart index ca0a3a4..1064f5b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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', diff --git a/lib/screens/home/home_controller.dart b/lib/screens/home/home_controller.dart index ba6fe78..d404882 100644 --- a/lib/screens/home/home_controller.dart +++ b/lib/screens/home/home_controller.dart @@ -10,6 +10,10 @@ part 'home_controller.g.dart'; class HomeScreenModel { List transactions = []; List categories = []; + List providers = []; + List 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 json) => + _$BillProviderFromJson(json); +} class HomeController extends ChangeNotifier { - final HomeScreenModel model = HomeScreenModel(); final Http http = Http(); + Future getProviders() async { + model.providers = getFakeProviders(); + try { + model.isLoading = true; + + List 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 getCategories() async { // activate skeletonizer model.categories = getFakeCategoryData(); @@ -45,16 +100,58 @@ class HomeController extends ChangeNotifier { model.isLoading = true; List 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 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 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 getFakeCategoryData() { return [ diff --git a/lib/screens/home/home_controller.freezed.dart b/lib/screens/home/home_controller.freezed.dart index 33e182d..a7ed97f 100644 --- a/lib/screens/home/home_controller.freezed.dart +++ b/lib/screens/home/home_controller.freezed.dart @@ -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 get copyWith => _$BillProviderCopyWithImpl(this as BillProvider, _$identity); + + /// Serializes this BillProvider to a JSON map. + Map 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 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 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 diff --git a/lib/screens/home/home_controller.g.dart b/lib/screens/home/home_controller.g.dart index 6c26abc..39fe3f1 100644 --- a/lib/screens/home/home_controller.g.dart +++ b/lib/screens/home/home_controller.g.dart @@ -19,3 +19,39 @@ Map _$CategoryToJson(_Category instance) => { 'image': instance.image, 'label': instance.label, }; + +_BillProvider _$BillProviderFromJson(Map 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 _$BillProviderToJson(_BillProvider instance) => + { + '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, + }; diff --git a/lib/screens/home/home_screen.dart b/lib/screens/home/home_screen.dart index 73e60ab..651f1a8 100644 --- a/lib/screens/home/home_screen.dart +++ b/lib/screens/home/home_screen.dart @@ -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 with SingleTickerProviderStateMixin { late AnimationController _controller; - late final Animation _alignAnimation; + late Animation _alignAnimation; final List> _animations = []; final List> _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 super.initState(); homeController.getCategories(); + homeController.getProviders(); + _controller = AnimationController( duration: const Duration(milliseconds: 600), vsync: this, @@ -75,6 +79,35 @@ class _HomeScreenState extends State }); } + 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 ); } - 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 @override Widget build(BuildContext context) { - final VoidCallback? onPressed = _enabled ? () {} : null; return Scaffold( body: SafeArea( child: CustomScrollView( @@ -119,204 +143,165 @@ class _HomeScreenState extends State 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 ), ); } + + 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, + ), + ), + ); + } } diff --git a/lib/screens/pay/pay_controller.dart b/lib/screens/pay/pay_controller.dart new file mode 100644 index 0000000..7c11e0d --- /dev/null +++ b/lib/screens/pay/pay_controller.dart @@ -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 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 json) => + _$BillProductFromJson(json); +} + +class PayController extends ChangeNotifier { + final PayScreenModel model = PayScreenModel(); + final Http http = Http(); + + void updateSelectedProduct(BillProduct product) { + model.selectedProduct = product; + notifyListeners(); + } + + Future getProducts(String providerId) async { + try { + model.isLoading = true; + + List 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(); + } + +} diff --git a/lib/screens/pay/pay_controller.freezed.dart b/lib/screens/pay/pay_controller.freezed.dart new file mode 100644 index 0000000..5921476 --- /dev/null +++ b/lib/screens/pay/pay_controller.freezed.dart @@ -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 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 get copyWith => _$BillProductCopyWithImpl(this as BillProduct, _$identity); + + /// Serializes this BillProduct to a JSON map. + Map 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 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 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 diff --git a/lib/screens/pay/pay_controller.g.dart b/lib/screens/pay/pay_controller.g.dart new file mode 100644 index 0000000..cd80089 --- /dev/null +++ b/lib/screens/pay/pay_controller.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'pay_controller.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_BillProduct _$BillProductFromJson(Map 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 _$BillProductToJson(_BillProduct instance) => + { + 'uid': instance.uid, + 'name': instance.name, + 'description': instance.description, + 'displayName': instance.displayName, + 'defaultAmount': instance.defaultAmount, + 'requiresAmount': instance.requiresAmount, + }; diff --git a/lib/screens/make_payment_screen.dart b/lib/screens/pay/pay_screen.dart similarity index 62% rename from lib/screens/make_payment_screen.dart rename to lib/screens/pay/pay_screen.dart index 6b288fe..9580580 100644 --- a/lib/screens/make_payment_screen.dart +++ b/lib/screens/pay/pay_screen.dart @@ -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 createState() => _MakePaymentScreenState(); + State createState() => _PayScreenState(); } -class _MakePaymentScreenState extends State - with SingleTickerProviderStateMixin { +class _PayScreenState extends State with TickerProviderStateMixin { late AnimationController _controller; late Animation _fadeAnimation; late Animation _slideAnimation; @@ -16,11 +18,12 @@ class _MakePaymentScreenState extends State final _formKey = GlobalKey(); 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 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( + 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 ), child: DropdownButtonHideUnderline( child: DropdownButton( - value: _selectedBillType, isExpanded: true, icon: const Icon(Icons.arrow_drop_down), items: [ - 'Electricity', - 'Water', - 'Internet', - 'Mobile', - 'Gas', - ].map((String value) { - return DropdownMenuItem( - value: value, - child: Text(value), - ); - }).toList(), + ...controller.model.products.map( + (e) => DropdownMenuItem( + value: e.uid, + child: Text(e.displayName), + ), + ), + ], onChanged: (String? newValue) { if (newValue != null) { setState(() { @@ -147,10 +164,7 @@ class _MakePaymentScreenState extends State 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 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 ), child: const Text( 'Pay Now', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), ), ); } -} \ No newline at end of file +}