Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions assets/languages/strings_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"contactSupportDescription": "FAQ, Tickets & Chat",
"continueAnyway": "Trotzdem fortfahren",
"copyClipboard": "In die Zwischenablage kopiert",
"countriesLoadFailed": "Die Länderliste konnte nicht geladen werden. Bitte versuchen Sie es erneut.",
"country": "Land",
"createWallet": "Neue Wallet erstellen",
"createWalletConfirm": "Ich habe es gesichert",
Expand Down Expand Up @@ -254,6 +255,7 @@
"sellSuccess": "Verkauf erfolgreich",
"sellSuccessDescription": "Der Betrag wird Ihnen auf das angegebene Bankkonto ausgezahlt.",
"sending": "Wird gesendet",
"setNationalityFailed": "Ihre Staatsangehörigkeit konnte nicht gesetzt werden:\n${message}",
"settings": "Einstellungen",
"settingsAppVersion": "Version ${tag}",
"settingsCurrency": "Währung",
Expand Down
2 changes: 2 additions & 0 deletions assets/languages/strings_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"contactSupportDescription": "FAQ, tickets & chat",
"continueAnyway": "Continue anyway",
"copyClipboard": "Copied to clipboard",
"countriesLoadFailed": "Could not load the country list. Please try again.",
"country": "Country",
"createWallet": "Create new Wallet",
"createWalletConfirm": "I’ve written it down",
Expand Down Expand Up @@ -254,6 +255,7 @@
"sellSuccess": "Sell successful",
"sellSuccessDescription": "The amount will be paid into the bank account you have specified.",
"sending": "Sending",
"setNationalityFailed": "Could not set your nationality:\n${message}",
"settings": "Settings",
"settingsAppVersion": "Version ${tag}",
"settingsCurrency": "Currency",
Expand Down
6 changes: 6 additions & 0 deletions lib/packages/service/dfx/models/country/country.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@ class Country extends Equatable {
final String symbol;
final String name;
final String? foreignName;
// Whether the backend accepts this country as a KYC address/residence
// country (DTO alias `kycAllowed`, backed by `dfxEnable`). A non-allowed
// address country is rejected with HTTP 400 on registration submit.
final bool kycAllowed;

const Country({
required this.id,
required this.symbol,
required this.name,
required this.kycAllowed,
this.foreignName,
});

Expand All @@ -25,6 +30,7 @@ extension DfxCountryDtoMapper on DfxCountryDto {
symbol: symbol,
name: name,
foreignName: foreignName,
kycAllowed: kycAllowed,
);
}
}
7 changes: 2 additions & 5 deletions lib/screens/kyc/steps/nationality/kyc_nationality_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class _KycNationalityViewState extends State<KycNationalityView> {
if (state is KycNationalityFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Set Nationality failed:\n${state.message}'),
content: Text(S.of(context).setNationalityFailed(state.message)),
backgroundColor: RealUnitColors.status.red600,
),
);
Expand All @@ -70,11 +70,8 @@ class _KycNationalityViewState extends State<KycNationalityView> {
children: [
CountryField(
label: S.of(context).registerCitizenship,
purpose: CountryFieldPurpose.nationality,
onChanged: (country) => nationalityCtrl.value = country,
validator: (value) {
if (value == null) return '';
return null;
},
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,8 @@ class KycRegistrationAddressStep extends StatelessWidget {
),
CountryField(
label: S.of(context).country,
purpose: CountryFieldPurpose.residence,
onChanged: (country) => countryCtrl.value = country,
validator: (value) {
if (value == null) return '';
return null;
},
),
Padding(
padding: const .symmetric(vertical: 16.0),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,8 @@ class KycRegistrationPersonalStep extends StatelessWidget {
),
CountryField(
label: S.of(context).registerCitizenship,
purpose: CountryFieldPurpose.nationality,
onChanged: (country) => nationalityCtrl.value = country,
validator: (value) {
if (value == null) return '';
return null;
},
),
Padding(
padding: const .symmetric(vertical: 16.0),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,8 @@ class _SettingsEditAddressViewState extends State<SettingsEditAddressView> {
),
CountryField(
label: S.of(context).country,
purpose: CountryFieldPurpose.residence,
onChanged: (country) => _countryCtrl.value = country,
validator: (value) {
if (value == null) return '';
return null;
},
),
FilePickerField(
label: S.of(context).proofDocument,
Expand Down
117 changes: 100 additions & 17 deletions lib/widgets/form/country_field.dart
Original file line number Diff line number Diff line change
@@ -1,19 +1,37 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:realunit_wallet/generated/i18n.dart';
import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart';
import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart';
import 'package:realunit_wallet/setup/di.dart';
import 'package:realunit_wallet/styles/colors.dart';
import 'package:realunit_wallet/widgets/form/dropdown_field.dart';

/// Selects how a [CountryField] gates its country list.
enum CountryFieldPurpose {
nationality,
residence
;

bool allows(Country country) => switch (this) {
// Nationality is a fact, not a permission: the backend routes a
// disallowed nationality to manual review instead of rejecting it,
// so the picker must offer every country.
CountryFieldPurpose.nationality => true,
CountryFieldPurpose.residence => country.kycAllowed,
};
}

class CountryField extends StatefulWidget {
final String label;
final CountryFieldPurpose purpose;
final void Function(Country?)? onChanged;
final String? Function(Country?)? validator;

const CountryField({
super.key,
required this.label,
required this.purpose,
this.onChanged,
this.validator,
});

@override
Expand All @@ -23,7 +41,6 @@ class CountryField extends StatefulWidget {
class _CountryFieldState extends State<CountryField> {
final DfxCountryService countryService = getIt<DfxCountryService>();
late Future<List<Country>> _countriesFuture;
bool _hasPreloaded = false;

@override
void initState() {
Expand All @@ -37,28 +54,56 @@ class _CountryFieldState extends State<CountryField> {
future: _countriesFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const SizedBox.shrink();
return _StatusField(
label: widget.label,
child: const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: CupertinoActivityIndicator(),
),
),
);
}
if (snapshot.hasError) {
return Text('Failed to load countries: ${snapshot.error}');
return _StatusField(
label: widget.label,
child: Row(
children: [
Expanded(
child: Text(
S.of(context).countriesLoadFailed,
style: TextStyle(color: RealUnitColors.status.red600),
),
),
TextButton(
onPressed: _retry,
child: Text(S.of(context).retry),
),
],
),
);
}

final countries = snapshot.data ?? [];
final initialCountry = countries.isNotEmpty ? countries.first : null;
_preloadCountry(initialCountry);
final countries = snapshot.data!.where(widget.purpose.allows).toList();

return DropdownField<Country>(
hintText: 'Schweiz',
label: widget.label,
items: countries.map((c) => DropdownMenuItem(value: c, child: Text(c.name))).toList(),
initialValue: initialCountry,
initialValue: null,
onChanged: widget.onChanged,
validator: widget.validator,
validator: (value) => value == null ? '' : null,
);
},
);
}

void _retry() {
setState(() {
_countriesFuture = _loadCountries();
});
}

Future<List<Country>> _loadCountries() async {
final countries = await countryService.getAllCountries();

Expand All @@ -80,13 +125,51 @@ class _CountryFieldState extends State<CountryField> {

return countries;
}
}

void _preloadCountry(Country? initialCountry) {
if (!_hasPreloaded && initialCountry != null) {
_hasPreloaded = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onChanged?.call(initialCountry);
});
}
/// A labeled container that always registers an *invalid* [FormField] while no
/// country can be selected (loading or error). This guarantees that
/// `Form.validate()` cannot return `true` before the user picks a country.
class _StatusField extends StatelessWidget {
final String label;
final Widget child;

const _StatusField({required this.label, required this.child});

@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
child: Text(
label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
height: 18 / 13,
),
),
),
FormField<Country>(
// No country is available yet, so the field is always invalid and
// blocks the surrounding Form from validating.
validator: (_) => '',
builder: (state) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.0),
border: Border.all(
color: state.hasError ? RealUnitColors.status.red600 : RealUnitColors.neutral300,
),
),
child: child,
);
},
),
],
);
}
}
73 changes: 44 additions & 29 deletions test/packages/service/dfx/models/aggregate_dtos_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,24 @@ void main() {

group('$Country', () {
test('equality is by id only', () {
const a = Country(id: 41, symbol: 'CH', name: 'Switzerland');
const b = Country(id: 41, symbol: 'XX', name: 'Different');
const c = Country(id: 49, symbol: 'DE', name: 'Germany');
const a = Country(
id: 41,
symbol: 'CH',
name: 'Switzerland',
kycAllowed: true,
);
const b = Country(
id: 41,
symbol: 'XX',
name: 'Different',
kycAllowed: true,
);
const c = Country(
id: 49,
symbol: 'DE',
name: 'Germany',
kycAllowed: true,
);

expect(a, b);
expect(a, isNot(c));
Expand Down Expand Up @@ -208,29 +223,29 @@ void main() {
}

Map<String, dynamic> _userDataJson() => {
'email': 'a@b.com',
'name': 'Ada Lovelace',
'type': 'HUMAN',
'phoneNumber': '+41 79 000 00 00',
'birthday': '1815-12-10',
'nationality': 'CH',
'addressStreet': 'Bahnhofstrasse 1',
'addressPostalCode': '8000',
'addressCity': 'Zurich',
'addressCountry': 'CH',
'swissTaxResidence': true,
'lang': 'de',
'kycData': {
'accountType': 'Personal',
'firstName': 'Ada',
'lastName': 'Lovelace',
'phone': '+41 79 000 00 00',
'address': {
'street': 'Bahnhofstrasse',
'houseNumber': '1',
'zip': '8000',
'city': 'Zurich',
'country': {'id': 41},
},
},
};
'email': 'a@b.com',
'name': 'Ada Lovelace',
'type': 'HUMAN',
'phoneNumber': '+41 79 000 00 00',
'birthday': '1815-12-10',
'nationality': 'CH',
'addressStreet': 'Bahnhofstrasse 1',
'addressPostalCode': '8000',
'addressCity': 'Zurich',
'addressCountry': 'CH',
'swissTaxResidence': true,
'lang': 'de',
'kycData': {
'accountType': 'Personal',
'firstName': 'Ada',
'lastName': 'Lovelace',
'phone': '+41 79 000 00 00',
'address': {
'street': 'Bahnhofstrasse',
'houseNumber': '1',
'zip': '8000',
'city': 'Zurich',
'country': {'id': 41},
},
},
};
Loading
Loading