diff --git a/.idea/libraries/Dart_Packages.xml b/.idea/libraries/Dart_Packages.xml index 3fce297..d7fd112 100644 --- a/.idea/libraries/Dart_Packages.xml +++ b/.idea/libraries/Dart_Packages.xml @@ -30,6 +30,13 @@ + + + + + + @@ -44,6 +51,13 @@ + + + + + + @@ -86,6 +100,13 @@ + + + + + + @@ -93,6 +114,20 @@ + + + + + + + + + + + + @@ -142,6 +177,13 @@ + + + + + + @@ -156,6 +198,20 @@ + + + + + + + + + + + + @@ -184,6 +240,13 @@ + + + + + + @@ -254,6 +317,20 @@ + + + + + + + + + + + + @@ -289,6 +366,13 @@ + + + + + + @@ -324,6 +408,13 @@ + + + + + + @@ -450,26 +541,35 @@ + + + + + + + + + @@ -480,15 +580,19 @@ + + + + diff --git a/README.md b/README.md index 004e847..9b9ee33 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,15 @@ -# sol_pay_gen +# Solana Pay QR generator QR code generator for Solana Pay -## Getting Started +## Checklist -This project is a starting point for a Flutter application. +- ✅ Validation -A few resources to get you started if this is your first Flutter project: - -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) - -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +- 🚧 Download QR code +- 🚧 SPL token picker +- 🚧 Upload custom icon +- 🚧 Adaptive layout +- 🚧 How it works page +- 🚧 Transaction request +- 🚧 Transactions history diff --git a/lib/data/base/text_value.dart b/lib/data/base/text_value.dart new file mode 100644 index 0000000..019ae13 --- /dev/null +++ b/lib/data/base/text_value.dart @@ -0,0 +1,22 @@ +import '../error/input_error.dart'; + +class TextValue { + TextValue({ + this.text = "", + this.error, + }); + + final String text; + final InputError? error; + + TextValue copyWith({ + String? text, + InputError? error, + }) => + TextValue( + text: text ?? this.text, + error: error ?? this.error, + ); + + bool isValid() => error == null; +} diff --git a/lib/data/error/input_error.dart b/lib/data/error/input_error.dart new file mode 100644 index 0000000..b552079 --- /dev/null +++ b/lib/data/error/input_error.dart @@ -0,0 +1,28 @@ +abstract class InputError { + abstract String text; +} + +class NotANumber extends InputError { + @override + String text = "Field must be a number"; +} + +class EmptyAmount extends InputError { + @override + String text = "Amount must be more than 0 if present"; +} + +class RequiredAmount extends InputError { + @override + String text = "Field is required"; +} + +class KeyNotBase58Encoded extends InputError { + @override + String text = "Invalid value, must be base58 encoded"; +} + +class KeyLengthInvalid extends InputError { + @override + String text = "Key value must be between 32 and 44 characters"; +} diff --git a/lib/data/solana_pay_request.dart b/lib/data/solana_pay_request.dart index fe21801..18fc5da 100644 --- a/lib/data/solana_pay_request.dart +++ b/lib/data/solana_pay_request.dart @@ -9,11 +9,11 @@ class SolanaPayRequest { this.splToken, }); - String address; - String? label; - String? splToken; - String? message; - String? amount; - String? reference; - String? memo; + final String address; + final String? label; + final String? splToken; + final String? message; + final String? amount; + final String? reference; + final String? memo; } diff --git a/lib/design/input/base_input.dart b/lib/design/input/base_input.dart index 0f5b151..66e72a2 100644 --- a/lib/design/input/base_input.dart +++ b/lib/design/input/base_input.dart @@ -5,6 +5,7 @@ class BaseInput extends StatelessWidget { final ValueChanged? _onChanged; final TextInputType? _keyboardType; final bool _focusable; + final String? _error; const BaseInput({ super.key, @@ -12,18 +13,20 @@ class BaseInput extends StatelessWidget { required onChanged, keyboardType, focusable, + error, }) : _labelText = labelText, _onChanged = onChanged, _keyboardType = keyboardType, - _focusable = focusable ?? true; + _focusable = focusable ?? true, + _error = error; @override Widget build(BuildContext context) { return TextField( decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: _labelText, - ), + border: const OutlineInputBorder(), + labelText: _labelText, + errorText: _error), onChanged: _onChanged, keyboardType: _keyboardType, focusNode: _focusable ? null : AlwaysDisabledFocusNode(), diff --git a/lib/di/blocs_providers.dart b/lib/di/blocs_providers.dart index 8d369e4..bc486b8 100644 --- a/lib/di/blocs_providers.dart +++ b/lib/di/blocs_providers.dart @@ -2,24 +2,27 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:sol_pay_gen/domain/generate_transfer_request_qr_use_case.dart'; import 'package:sol_pay_gen/feature/qr/bloc/qr_generator_cubit.dart'; +import 'package:sol_pay_gen/validator/keys_validator.dart'; +import 'package:sol_pay_gen/validator/number_validator.dart'; import '../data/transfer/transfer_request_repository.dart'; import '../feature/input/bloc/parameters_input_cubit.dart'; MultiBlocProvider getBlocProviders({required StatelessWidget child}) { - ParametersInputCubit parametersInputCubit = ParametersInputCubit(); - return MultiBlocProvider( providers: [ BlocProvider( - create: (_) => parametersInputCubit, + create: (BuildContext context) => ParametersInputCubit( + context.read(), + context.read(), + ), ), BlocProvider( create: (BuildContext context) => QrGeneratorCubit( generateTransferRequestQrUseCase: GenerateTransferRequestQrUseCase( context.read(), ), - parametersInputCubit: parametersInputCubit, + parametersInputCubit: context.read(), ), ) ], diff --git a/lib/di/repositories_providers.dart b/lib/di/repositories_providers.dart index b34d30b..9de3dcd 100644 --- a/lib/di/repositories_providers.dart +++ b/lib/di/repositories_providers.dart @@ -1,5 +1,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:sol_pay_gen/data/transfer/transfer_request_repository.dart'; +import 'package:sol_pay_gen/validator/keys_validator.dart'; +import 'package:sol_pay_gen/validator/number_validator.dart'; MultiRepositoryProvider getRepositoryProviders({ required MultiBlocProvider blocProviders, @@ -7,8 +9,14 @@ MultiRepositoryProvider getRepositoryProviders({ return MultiRepositoryProvider( providers: [ RepositoryProvider( - create: (context) => DefaultTransferRequestRepository(), + create: (_) => DefaultTransferRequestRepository(), ), + RepositoryProvider( + create: (_) => DefaultNumberValidator(), + ), + RepositoryProvider( + create: (_) => DefaultKeysValidator(), + ) ], child: blocProviders, ); diff --git a/lib/feature/input/bloc/parameters_input_cubit.dart b/lib/feature/input/bloc/parameters_input_cubit.dart index f51c07a..e798ad4 100644 --- a/lib/feature/input/bloc/parameters_input_cubit.dart +++ b/lib/feature/input/bloc/parameters_input_cubit.dart @@ -1,13 +1,23 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:sol_pay_gen/data/base/text_value.dart'; import 'package:sol_pay_gen/feature/input/bloc/parameters_input_state.dart'; +import '../../../data/error/input_error.dart'; +import '../../../validator/keys_validator.dart'; +import '../../../validator/number_validator.dart'; + class ParametersInputCubit extends Cubit { - ParametersInputCubit() - : super( - const ParametersInputState( - address: "", - amount: null, - reference: null, + final NumberValidator _numberValidator; + final KeysValidator _keysValidator; + + ParametersInputCubit( + this._numberValidator, + this._keysValidator, + ) : super( + ParametersInputState( + address: TextValue(), + amount: TextValue(), + reference: TextValue(), memo: null, message: null, label: null, @@ -16,11 +26,21 @@ class ParametersInputCubit extends Cubit { ); void onAddressChange(String address) { - emit(state.copyWith(address: address)); + emit(state.copyWith( + address: TextValue( + text: address, + error: _validateAddress(address), + ), + )); } void onAmountChange(String amount) { - emit(state.copyWith(amount: amount)); + emit(state.copyWith( + amount: TextValue( + text: amount, + error: amount.isEmpty ? null : _numberValidator.validateAmount(amount), + ), + )); } void onLabelChange(String label) { @@ -32,7 +52,12 @@ class ParametersInputCubit extends Cubit { } void onReferenceChange(String reference) { - emit(state.copyWith(reference: reference)); + emit(state.copyWith( + reference: TextValue( + text: reference, + error: reference.isEmpty ? null : _keysValidator.validateKey(reference), + ), + )); } void onSplTokenChange(String token) { @@ -42,4 +67,18 @@ class ParametersInputCubit extends Cubit { void onMemoChange(String memo) { emit(state.copyWith(memo: memo)); } + + void onValidate() { + emit(state.copyWith( + address: state.address.copyWith( + error: _validateAddress(state.address.text), + ), + )); + } + + InputError? _validateAddress(String address) { + return address.isEmpty + ? RequiredAmount() + : _keysValidator.validateKey(address); + } } diff --git a/lib/feature/input/bloc/parameters_input_state.dart b/lib/feature/input/bloc/parameters_input_state.dart index c8ad789..d9d5b7c 100644 --- a/lib/feature/input/bloc/parameters_input_state.dart +++ b/lib/feature/input/bloc/parameters_input_state.dart @@ -1,9 +1,10 @@ import 'package:equatable/equatable.dart'; +import 'package:sol_pay_gen/data/base/text_value.dart'; class ParametersInputState extends Equatable { - final String address; - final String? amount; - final String? reference; + final TextValue address; + final TextValue amount; + final TextValue reference; final String? label; final String? message; final String? memo; @@ -30,11 +31,11 @@ class ParametersInputState extends Equatable { ]; ParametersInputState copyWith({ - String? address, - String? amount, + TextValue? address, + TextValue? amount, String? label, String? message, - String? reference, + TextValue? reference, String? memo, String? splTokenAddress, }) => @@ -47,4 +48,7 @@ class ParametersInputState extends Equatable { memo: memo ?? this.memo, splTokenAddress: splTokenAddress ?? this.splTokenAddress, ); + + bool isValid() => + amount.isValid() && address.isValid() && reference.isValid(); } diff --git a/lib/feature/input/parameters_input_screen.dart b/lib/feature/input/parameters_input_screen.dart index 4ef71b2..55978f4 100644 --- a/lib/feature/input/parameters_input_screen.dart +++ b/lib/feature/input/parameters_input_screen.dart @@ -49,6 +49,7 @@ class InputBody extends StatelessWidget { children: [ BaseInput( labelText: 'Receive wallet address', + error: state.address.error?.text, onChanged: (address) => context .read() .onAddressChange(address), @@ -57,6 +58,7 @@ class InputBody extends StatelessWidget { BaseInput( labelText: 'Amount', keyboardType: TextInputType.number, + error: state.amount.error?.text, onChanged: (address) => context .read() .onAmountChange(address), @@ -77,6 +79,7 @@ class InputBody extends StatelessWidget { const Padding(padding: EdgeInsets.only(top: 16.0)), BaseInput( labelText: 'Reference', + error: state.reference.error?.text, onChanged: (address) => context .read() .onReferenceChange(address), @@ -101,7 +104,10 @@ class InputBody extends StatelessWidget { Padding( padding: const EdgeInsets.all(24.0), child: MaterialButton( - onPressed: () => context.read().onGenerate(), + onPressed: () { + context.read().onValidate(); + context.read().onGenerate(); + }, color: Colors.blueAccent, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16.0), diff --git a/lib/feature/qr/bloc/qr_generator_cubit.dart b/lib/feature/qr/bloc/qr_generator_cubit.dart index a5828f8..b6d84a9 100644 --- a/lib/feature/qr/bloc/qr_generator_cubit.dart +++ b/lib/feature/qr/bloc/qr_generator_cubit.dart @@ -19,18 +19,21 @@ class QrGeneratorCubit extends Cubit { void onGenerate() async { ParametersInputState inputState = _parametersInputCubit.state; - SolanaPayRequest request = SolanaPayRequest( - address: inputState.address, - label: inputState.label, - message: inputState.message, - amount: inputState.amount, - reference: inputState.reference, - memo: inputState.memo, - splToken: inputState.splTokenAddress, - ); - String qrCode = _generateTransferRequestQrUseCase.execute(request); + if (inputState.isValid()) { + SolanaPayRequest request = SolanaPayRequest( + address: inputState.address.text, + label: inputState.label, + message: inputState.message, + amount: inputState.amount.text, + reference: inputState.reference.text, + memo: inputState.memo, + splToken: inputState.splTokenAddress, + ); - emit(QrCode(qrCode)); + String qrCode = _generateTransferRequestQrUseCase.execute(request); + + emit(QrCode(qrCode)); + } } } diff --git a/lib/validator/keys_validator.dart b/lib/validator/keys_validator.dart new file mode 100644 index 0000000..ae7cc5f --- /dev/null +++ b/lib/validator/keys_validator.dart @@ -0,0 +1,27 @@ +import 'package:solana/base58.dart'; + +import '../data/error/input_error.dart'; + +abstract class KeysValidator { + InputError? validateKey(String key); +} + +class DefaultKeysValidator extends KeysValidator { + // https://docs.solana.com/cli/transfer-tokens#:~:text=The%20public%20key%20is%20a,from%2032%20to%2044%20characters. + static const minSolanaKeyLength = 32; + static const maxSolanaKeyLength = 44; + + @override + InputError? validateKey(String key) { + if (key.length < minSolanaKeyLength || key.length > maxSolanaKeyLength) { + return KeyLengthInvalid(); + } else { + try { + base58decode(key); + return null; + } on FormatException catch (_) { + return KeyNotBase58Encoded(); + } + } + } +} diff --git a/lib/validator/number_validator.dart b/lib/validator/number_validator.dart new file mode 100644 index 0000000..cfe8b25 --- /dev/null +++ b/lib/validator/number_validator.dart @@ -0,0 +1,21 @@ +import '../data/error/input_error.dart'; + +abstract class NumberValidator { + InputError? validateAmount(String amount); +} + +class DefaultNumberValidator extends NumberValidator { + @override + InputError? validateAmount(String amount) { + double? parsedAmount = double.tryParse(amount); + + if (parsedAmount == null) { + return NotANumber(); + } + if (parsedAmount == 0) { + return EmptyAmount(); + } else { + return null; + } + } +} diff --git a/pubspec.lock b/pubspec.lock index 903bafd..b0a9240 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + bip39: + dependency: transitive + description: + name: bip39 + sha256: de1ee27ebe7d96b84bb3a04a4132a0a3007dcdd5ad27dd14aa87a29d97c45edc + url: "https://pub.dev" + source: hosted + version: "1.0.6" bloc: dependency: transitive description: @@ -49,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + borsh_annotation: + dependency: transitive + description: + name: borsh_annotation + sha256: "8c2cc353cb99a12b6c4f9c69e3640d2e18f5127628391658b9fceb96d4fec4d6" + url: "https://pub.dev" + source: hosted + version: "0.3.1+4" characters: dependency: transitive description: @@ -97,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + cryptography: + dependency: transitive + description: + name: cryptography + sha256: df156c5109286340817d21fa7b62f9140f17915077127dd70f8bd7a2a0997a35 + url: "https://pub.dev" + source: hosted + version: "2.5.0" cupertino_icons: dependency: "direct main" description: @@ -105,6 +129,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + decimal: + dependency: transitive + description: + name: decimal + sha256: "24a261d5d5c87e86c7651c417a5dbdf8bcd7080dd592533910e8d0505a279f21" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + ed25519_hd_key: + dependency: transitive + description: + name: ed25519_hd_key + sha256: c5c9f11a03f5789bf9dcd9ae88d641571c802640851f1cacdb13123f171b3a26 + url: "https://pub.dev" + source: hosted + version: "2.2.1" equatable: dependency: "direct main" description: @@ -155,6 +195,14 @@ packages: description: flutter source: sdk version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d + url: "https://pub.dev" + source: hosted + version: "2.4.1" frontend_server_client: dependency: transitive description: @@ -171,6 +219,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + hex: + dependency: transitive + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + http: + dependency: transitive + description: + name: http + sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" + url: "https://pub.dev" + source: hosted + version: "0.13.6" http_multi_server: dependency: transitive description: @@ -203,6 +267,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + url: "https://pub.dev" + source: hosted + version: "4.8.1" lints: dependency: transitive description: @@ -283,6 +355,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.8.3" + pinenacl: + dependency: transitive + description: + name: pinenacl + sha256: "3a5503637587d635647c93ea9a8fecf48a420cc7deebe6f1fc85c2a5637ab327" + url: "https://pub.dev" + source: hosted + version: "0.5.1" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" + url: "https://pub.dev" + source: hosted + version: "3.7.3" pool: dependency: transitive description: @@ -323,6 +411,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + rational: + dependency: transitive + description: + name: rational + sha256: ba58e9e18df9abde280e8b10051e4bce85091e41e8e7e411b6cde2e738d357cf + url: "https://pub.dev" + source: hosted + version: "2.2.2" shelf: dependency: transitive description: @@ -360,6 +456,14 @@ packages: description: flutter source: sdk version: "0.0.99" + solana: + dependency: "direct main" + description: + name: solana + sha256: "24f6e87a28035ce7ce54a488f7476432fc5f9ffccd70547f514a2ec9c6251c87" + url: "https://pub.dev" + source: hosted + version: "0.30.0" source_map_stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 97068b4..5567ddc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: equatable: ^2.0.5 cupertino_icons: ^1.0.2 pretty_qr_code: ^2.0.3 + solana: ^0.30.0 dev_dependencies: flutter_test: