diff --git a/mobile-app/android/app/src/main/AndroidManifest.xml b/mobile-app/android/app/src/main/AndroidManifest.xml index b64593bf..2d6320d9 100644 --- a/mobile-app/android/app/src/main/AndroidManifest.xml +++ b/mobile-app/android/app/src/main/AndroidManifest.xml @@ -47,6 +47,7 @@ + diff --git a/mobile-app/lib/features/components/button.dart b/mobile-app/lib/features/components/button.dart index 0a6901a4..28842fc3 100644 --- a/mobile-app/lib/features/components/button.dart +++ b/mobile-app/lib/features/components/button.dart @@ -3,8 +3,9 @@ import 'package:flutter/material.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; -enum ButtonVariant { transparent, neutral, primary, success, danger, glass, glassOutline, dangerOutline } +enum ButtonVariant { transparent, neutral, primary, success, danger, glass, glassOutline, dangerOutline, accent } class Button extends StatelessWidget { final String label; @@ -34,6 +35,8 @@ class Button extends StatelessWidget { switch (variant) { case ButtonVariant.neutral: return context.themeColors.textSecondary; + case ButtonVariant.accent: + return Colors.black; default: return null; } @@ -169,6 +172,18 @@ class Button extends StatelessWidget { ); break; + case ButtonVariant.accent: + buttonWidget = Container( + width: width, + padding: padding, + decoration: ShapeDecoration( + color: disabled ? disabledBtnColor : context.colors.accentGreen, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(buttonRadius)), + ), + child: buttonContent, + ); + break; + default: buttonWidget = ClipRRect( borderRadius: BorderRadius.circular(buttonRadius), diff --git a/mobile-app/lib/features/components/shared_address_action_sheet.dart b/mobile-app/lib/features/components/shared_address_action_sheet.dart index 1d16a9db..e40481ee 100644 --- a/mobile-app/lib/features/components/shared_address_action_sheet.dart +++ b/mobile-app/lib/features/components/shared_address_action_sheet.dart @@ -9,6 +9,7 @@ import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart'; import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_sheet.dart'; class SharedAddressActionSheet extends StatefulWidget { final String address; @@ -56,7 +57,8 @@ class _SharedAddressActionSheetState extends State { } void _sendToAddress() { - Navigator.of(context).pushNamed('/send', arguments: widget.address); + Navigator.of(context).pop(); + showSendSheetV2(context, address: widget.address); } void _closeSheet() { diff --git a/mobile-app/lib/providers/route_intent_providers.dart b/mobile-app/lib/providers/route_intent_providers.dart index a0fe38d6..4b211420 100644 --- a/mobile-app/lib/providers/route_intent_providers.dart +++ b/mobile-app/lib/providers/route_intent_providers.dart @@ -3,3 +3,13 @@ import 'package:quantus_sdk/quantus_sdk.dart'; final transactionIntentProvider = StateProvider((_) => null); final sharedAccountIntentProvider = StateProvider((_) => null); + +class PaymentIntent { + final String to; + final String amount; + final String? ref; + + const PaymentIntent({required this.to, required this.amount, this.ref}); +} + +final paymentIntentProvider = StateProvider((_) => null); diff --git a/mobile-app/lib/providers/wallet_providers.dart b/mobile-app/lib/providers/wallet_providers.dart index 4aac4d12..3cc73900 100644 --- a/mobile-app/lib/providers/wallet_providers.dart +++ b/mobile-app/lib/providers/wallet_providers.dart @@ -152,3 +152,19 @@ class IsBalanceHiddenNotifier extends StateNotifier { state = value; } } + +final posModeProvider = StateNotifierProvider((ref) { + final settingsService = ref.watch(settingsServiceProvider); + return PosModeNotifier(settingsService); +}); + +class PosModeNotifier extends StateNotifier { + final SettingsService _settingsService; + + PosModeNotifier(this._settingsService) : super(_settingsService.isPosModeEnabled()); + + Future setPosMode(bool value) async { + await _settingsService.setPosModeEnabled(value); + state = value; + } +} diff --git a/mobile-app/lib/services/deep_link_service.dart b/mobile-app/lib/services/deep_link_service.dart index 0cae95fe..d58a8d8d 100644 --- a/mobile-app/lib/services/deep_link_service.dart +++ b/mobile-app/lib/services/deep_link_service.dart @@ -53,6 +53,19 @@ class DeepLinkService { } } + if (uri.pathSegments.isNotEmpty && uri.pathSegments.first == 'pay') { + final to = uri.queryParameters['to']; + final amount = uri.queryParameters['amount']; + final ref = uri.queryParameters['ref']; + + if (to != null && to.isNotEmpty && amount != null && amount.isNotEmpty) { + _ref.read(paymentIntentProvider.notifier).state = PaymentIntent(to: to, amount: amount, ref: ref); + navigatorKey.currentState?.pushNamed('/account'); + } else { + print('Missing payment parameters'); + } + } + if (uri.pathSegments.isNotEmpty && uri.pathSegments.first == 'oauth') { _ref.invalidate(accountAssociationsProvider); } diff --git a/mobile-app/lib/services/pos_service.dart b/mobile-app/lib/services/pos_service.dart new file mode 100644 index 00000000..b005c5e2 --- /dev/null +++ b/mobile-app/lib/services/pos_service.dart @@ -0,0 +1,25 @@ +class PosPaymentRequest { + final String paymentUrl; + final String refId; + final String amount; + + const PosPaymentRequest({required this.paymentUrl, required this.refId, required this.amount}); +} + +class PosService { + String generateRefId() { + final now = DateTime.now().millisecondsSinceEpoch; + return now.toRadixString(36).toUpperCase(); + } + + String buildPaymentUrl({required String accountId, required String amount, required String refId}) { + final uri = Uri.https('www.quantus.com', '/pay', {'to': accountId, 'amount': amount, 'ref': refId}); + return uri.toString(); + } + + PosPaymentRequest createPaymentRequest({required String accountId, required String amount}) { + final refId = generateRefId(); + final url = buildPaymentUrl(accountId: accountId, amount: amount, refId: refId); + return PosPaymentRequest(paymentUrl: url, refId: refId, amount: amount); + } +} diff --git a/mobile-app/lib/v2/screens/home/home_screen.dart b/mobile-app/lib/v2/screens/home/home_screen.dart index 7f89da8c..7e420105 100644 --- a/mobile-app/lib/v2/screens/home/home_screen.dart +++ b/mobile-app/lib/v2/screens/home/home_screen.dart @@ -4,15 +4,17 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/components/account_gradient_image.dart'; +import 'package:resonance_network_wallet/features/components/button.dart'; import 'package:resonance_network_wallet/features/components/shared_address_action_sheet.dart'; import 'package:resonance_network_wallet/features/components/skeleton.dart'; -import 'package:resonance_network_wallet/v2/components/glass_button.dart'; +import 'package:resonance_network_wallet/v2/components/glass_button.dart' hide ButtonVariant; import 'package:resonance_network_wallet/v2/components/glass_icon_button.dart'; import 'package:resonance_network_wallet/v2/screens/accounts/accounts_sheet.dart'; import 'package:resonance_network_wallet/v2/screens/receive/receive_sheet.dart'; import 'package:resonance_network_wallet/v2/screens/send/send_sheet.dart'; import 'package:resonance_network_wallet/v2/screens/settings/settings_screen.dart'; import 'package:resonance_network_wallet/utils/feature_flags.dart'; +import 'package:resonance_network_wallet/v2/screens/pos/pos_amount_screen.dart'; import 'package:resonance_network_wallet/v2/screens/swap/swap_screen.dart'; import 'package:resonance_network_wallet/providers/account_id_list_cache.dart'; import 'package:resonance_network_wallet/providers/account_providers.dart'; @@ -48,6 +50,15 @@ class _HomeScreenState extends ConsumerState { } void _processIntentIfAvailable() { + final payment = ref.read(paymentIntentProvider); + if (payment != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(paymentIntentProvider.notifier).state = null; + showSendSheetV2(context, address: payment.to, amount: payment.amount, isPayMode: true); + }); + return; + } + final shared = ref.read(sharedAccountIntentProvider); if (shared != null) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -67,13 +78,14 @@ class _HomeScreenState extends ConsumerState { _processIntentIfAvailable(); final isBalanceHidden = ref.watch(isBalanceHiddenProvider); + final isPosMode = ref.watch(posModeProvider); final accountAsync = ref.watch(activeAccountProvider); final balanceAsync = ref.watch(balanceProvider); final txAsync = ref.watch(activeAccountTransactionsProvider); final colors = context.colors; final text = context.themeText; - return accountAsync.when( + Widget screen = accountAsync.when( loading: () => ScaffoldBase( child: Center(child: CircularProgressIndicator(color: colors.textPrimary)), ), @@ -91,11 +103,34 @@ class _HomeScreenState extends ConsumerState { slivers: [ _buildContent(active, balanceAsync, isBalanceHidden, colors, text), ActivitySection(txAsync: txAsync, activeAccount: active.account, onRetry: _refresh), - const SizedBox(height: 58), + SizedBox(height: isPosMode ? 120 : 58), ], ); }, ); + + if (!isPosMode) return screen; + + return Stack( + children: [ + screen, + Positioned( + left: 24, + right: 24, + bottom: MediaQuery.of(context).padding.bottom + 24, + child: _buildPosButton(colors, text), + ), + ], + ); + } + + Widget _buildPosButton(AppColorsV2 colors, AppTextTheme text) { + return Button( + label: 'New Charge', + variant: ButtonVariant.accent, + onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const PosAmountScreen())), + textStyle: text.smallTitle?.copyWith(fontWeight: FontWeight.w700, fontSize: 20, decoration: TextDecoration.none), + ); } Widget _buildContent( diff --git a/mobile-app/lib/v2/screens/pos/pos_amount_screen.dart b/mobile-app/lib/v2/screens/pos/pos_amount_screen.dart new file mode 100644 index 00000000..79e3a2a1 --- /dev/null +++ b/mobile-app/lib/v2/screens/pos/pos_amount_screen.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/features/components/button.dart'; +import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; +import 'package:resonance_network_wallet/v2/screens/pos/pos_qr_screen.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +class PosAmountScreen extends ConsumerStatefulWidget { + const PosAmountScreen({super.key}); + + @override + ConsumerState createState() => _PosAmountScreenState(); +} + +class _PosAmountScreenState extends ConsumerState { + String _input = '0'; + final _fmt = NumberFormattingService(); + final _decimalFilter = DecimalInputFilter(); + + void _onDigit(String digit) { + final oldText = _input == '0' && digit != '.' && digit != ',' ? '' : _input; + final newText = oldText + digit; + + final oldValue = TextEditingValue(text: oldText); + final newValue = TextEditingValue(text: newText); + + final formatted = _decimalFilter.formatEditUpdate(oldValue, newValue); + + setState(() { + _input = formatted.text.isEmpty ? '0' : formatted.text; + }); + } + + void _onBackspace() { + setState(() { + if (_input.length <= 1) { + _input = '0'; + } else { + _input = _input.substring(0, _input.length - 1); + } + }); + } + + void _onClear() => setState(() => _input = '0'); + + void _onCharge() { + final amount = _fmt.parseAmount(_input); + if (amount == null || amount <= BigInt.zero) return; + Navigator.push(context, MaterialPageRoute(builder: (_) => PosQrScreen(amount: _input))); + } + + bool get _isValid { + final amount = _fmt.parseAmount(_input); + return amount != null && amount > BigInt.zero; + } + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + + return ScaffoldBase( + appBar: const V2AppBar(title: 'New Charge'), + child: Column( + children: [ + Expanded( + child: Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + '$_input ${AppConstants.tokenSymbol}', + style: text.extraLargeTitle?.copyWith( + color: colors.textPrimary, + fontSize: 56, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + _buildKeypad(colors, text), + const SizedBox(height: 16), + _buildChargeButton(colors, text), + const SizedBox(height: 24), + ], + ), + ); + } + + Widget _buildKeypad(AppColorsV2 colors, AppTextTheme text) { + final decimalSeparator = NumberFormat().symbols.DECIMAL_SEP; + final keys = [ + ['1', '2', '3'], + ['4', '5', '6'], + ['7', '8', '9'], + [decimalSeparator, '0', 'backspace'], + ]; + + return Column( + children: keys.map((row) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: row.map((key) => _buildKey(key, colors, text)).toList(), + ), + ); + }).toList(), + ); + } + + Widget _buildKey(String key, AppColorsV2 colors, AppTextTheme text) { + return Expanded( + child: GestureDetector( + onTap: () => key == 'backspace' ? _onBackspace() : _onDigit(key), + onLongPress: key == 'backspace' ? _onClear : null, + behavior: HitTestBehavior.opaque, + child: Container( + height: 60, + alignment: Alignment.center, + child: key == 'backspace' + ? Icon(Icons.backspace_outlined, color: colors.textPrimary, size: 28) + : Text(key, style: text.mediumTitle?.copyWith(color: colors.textPrimary, fontSize: 28)), + ), + ), + ); + } + + Widget _buildChargeButton(AppColorsV2 colors, AppTextTheme text) { + final disabled = !_isValid; + return Button( + label: _isValid ? 'Charge $_input ${AppConstants.tokenSymbol}' : 'Enter Amount', + variant: ButtonVariant.accent, + isDisabled: disabled, + onPressed: _onCharge, + textStyle: text.smallTitle?.copyWith(fontWeight: FontWeight.w700), + ); + } +} diff --git a/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart b/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart new file mode 100644 index 00000000..fd1d9fe7 --- /dev/null +++ b/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/services/pos_service.dart'; +import 'package:resonance_network_wallet/v2/components/glass_button.dart'; +import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +class PosQrScreen extends ConsumerStatefulWidget { + final String amount; + const PosQrScreen({super.key, required this.amount}); + + @override + ConsumerState createState() => _PosQrScreenState(); +} + +class _PosQrScreenState extends ConsumerState { + final _posService = PosService(); + PosPaymentRequest? _request; + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + final accountAsync = ref.watch(activeAccountProvider); + + return ScaffoldBase( + appBar: const V2AppBar(title: 'Scan to Pay'), + child: accountAsync.when( + loading: () => Center(child: CircularProgressIndicator(color: colors.textPrimary)), + error: (e, _) => Center( + child: Text('Error: $e', style: text.detail?.copyWith(color: colors.textError)), + ), + data: (active) { + if (active == null) return const Center(child: Text('No active account')); + _request ??= _posService.createPaymentRequest(accountId: active.account.accountId, amount: widget.amount); + debugPrint('POS Payment URL: ${_request!.paymentUrl}'); + return _buildContent(_request!, colors, text); + }, + ), + ); + } + + Widget _buildContent(PosPaymentRequest request, AppColorsV2 colors, AppTextTheme text) { + return Column( + children: [ + const Spacer(), + Text( + '${request.amount} ${AppConstants.tokenSymbol}', + style: text.extraLargeTitle?.copyWith(color: colors.textPrimary, fontSize: 40), + ), + const SizedBox(height: 32), + _buildQrCode(request.paymentUrl, colors), + const SizedBox(height: 16), + Text('Ref: ${request.refId}', style: text.detail?.copyWith(color: colors.textTertiary)), + const Spacer(), + GlassButton.simple(label: 'New Charge', onTap: () => Navigator.pop(context), variant: ButtonVariant.secondary), + const SizedBox(height: 16), + GlassButton.simple( + label: 'Done', + onTap: () => Navigator.of(context).popUntil((route) => route.isFirst), + variant: ButtonVariant.primary, + ), + const SizedBox(height: 24), + ], + ); + } + + Widget _buildQrCode(String data, AppColorsV2 colors) { + return Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: QrImageView( + data: data, + version: QrVersions.auto, + size: 280, + padding: const EdgeInsets.all(16), + backgroundColor: Colors.white, + eyeStyle: const QrEyeStyle(eyeShape: QrEyeShape.square, color: Colors.black), + dataModuleStyle: const QrDataModuleStyle(dataModuleShape: QrDataModuleShape.square, color: Colors.black), + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/send/send_sheet.dart b/mobile-app/lib/v2/screens/send/send_sheet.dart index 0062da7b..f946eabc 100644 --- a/mobile-app/lib/v2/screens/send/send_sheet.dart +++ b/mobile-app/lib/v2/screens/send/send_sheet.dart @@ -19,7 +19,9 @@ enum _Step { form, confirm, sending, complete } class SendSheet extends ConsumerStatefulWidget { final String? initialAddress; - const SendSheet({super.key, this.initialAddress}); + final String? initialAmount; + final bool isPayMode; + const SendSheet({super.key, this.initialAddress, this.initialAmount, this.isPayMode = false}); @override ConsumerState createState() => _SendSheetState(); @@ -48,6 +50,9 @@ class _SendSheetState extends ConsumerState { if (widget.initialAddress != null) { _recipientController.text = widget.initialAddress!; } + if (widget.initialAmount != null) { + _amountController.text = widget.initialAmount!; + } } @override @@ -169,7 +174,7 @@ class _SendSheetState extends ConsumerState { final balance = ref.watch(effectiveMaxBalanceProvider); return BottomSheetContainer( - title: 'Send', + title: widget.isPayMode ? 'Pay' : 'Send', onBack: _step == _Step.confirm ? _backToForm : null, child: AnimatedSize( duration: const Duration(milliseconds: 200), @@ -404,7 +409,10 @@ class _SendSheetState extends ConsumerState { const SizedBox(height: 48), CircularProgressIndicator(color: colors.textPrimary), const SizedBox(height: 24), - Text('Sending...', style: text.smallTitle?.copyWith(color: colors.textPrimary)), + Text( + widget.isPayMode ? 'Paying...' : 'Sending...', + style: text.smallTitle?.copyWith(color: colors.textPrimary), + ), const SizedBox(height: 80), ], ); @@ -417,7 +425,7 @@ class _SendSheetState extends ConsumerState { const SizedBox(height: 48), const SuccessCheck(size: 64), const SizedBox(height: 24), - Text('Sent!', style: text.smallTitle?.copyWith(color: colors.textPrimary)), + Text(widget.isPayMode ? 'Paid!' : 'Sent!', style: text.smallTitle?.copyWith(color: colors.textPrimary)), const SizedBox(height: 8), Text( '${_fmt.formatBalance(_amount)} ${AppConstants.tokenSymbol}', @@ -444,12 +452,12 @@ class _SendSheetState extends ConsumerState { } } -void showSendSheetV2(BuildContext context, {String? address}) { +void showSendSheetV2(BuildContext context, {String? address, String? amount, bool isPayMode = false}) { BottomSheetContainer.show( context, builder: (_) => Padding( padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), - child: SendSheet(initialAddress: address), + child: SendSheet(initialAddress: address, initialAmount: amount, isPayMode: isPayMode), ), ); } diff --git a/mobile-app/lib/v2/screens/settings/settings_screen.dart b/mobile-app/lib/v2/screens/settings/settings_screen.dart index 63e6ea99..516d3c6a 100644 --- a/mobile-app/lib/v2/screens/settings/settings_screen.dart +++ b/mobile-app/lib/v2/screens/settings/settings_screen.dart @@ -13,6 +13,7 @@ import 'package:resonance_network_wallet/providers/account_associations_provider import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/providers/notification_config_provider.dart'; import 'package:resonance_network_wallet/providers/pending_transactions_provider.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/shared/utils/account_utils.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; @@ -99,6 +100,7 @@ class _SettingsScreenV2State extends ConsumerState { final colors = context.colors; final text = context.themeText; final notifConfig = ref.watch(notificationConfigProvider); + final posMode = ref.watch(posModeProvider); return ScaffoldBase( appBar: const V2AppBar(title: 'Settings'), @@ -134,8 +136,15 @@ class _SettingsScreenV2State extends ConsumerState { ]), const SizedBox(height: 40), _section('Preferences', colors, text, [ - // _chevronItem('Currency', 'USD (\$)', colors, text, onTap: () {}), - // _divider(colors), + _toggleItem( + 'POS Mode', + posMode ? 'Point of Sale Enabled' : 'Disabled', + posMode, + (v) => ref.read(posModeProvider.notifier).setPosMode(v), + colors, + text, + ), + _divider(colors), _toggleItem( 'Notifications', notifConfig.enabled ? 'Transaction Alerts Enabled' : 'Alerts Disabled', diff --git a/quantus_sdk/lib/src/services/settings_service.dart b/quantus_sdk/lib/src/services/settings_service.dart index ce2495ca..715b84ee 100644 --- a/quantus_sdk/lib/src/services/settings_service.dart +++ b/quantus_sdk/lib/src/services/settings_service.dart @@ -287,6 +287,17 @@ class SettingsService { return _prefs.getBool(_balanceHiddenKey) ?? false; } + // POS Mode Settings + static const String _posModeEnabledKey = 'pos_mode_enabled'; + + Future setPosModeEnabled(bool enabled) async { + await _prefs.setBool(_posModeEnabledKey, enabled); + } + + bool isPosModeEnabled() { + return _prefs.getBool(_posModeEnabledKey) ?? false; + } + // --- Primitive Accessors for General Use --- /// Get a boolean value from SharedPreferences