From 54d66db1bcd538f4e57854bf4c0be581495a76d5 Mon Sep 17 00:00:00 2001 From: xavierchanth Date: Tue, 19 Nov 2024 22:34:11 -0500 Subject: [PATCH 1/6] feat: new onboarding flow - status and error visual indicators to be implemented --- .../onboarding/util/onboarding_util.dart | 33 ++ .../onboarding/widgets/onboarding_button.dart | 322 ++++++++++++------ .../lib/widgets/custom_text_button.dart | 2 +- packages/dart/npt_flutter/pubspec.lock | 6 +- packages/dart/npt_flutter/pubspec.yaml | 5 +- 5 files changed, 261 insertions(+), 107 deletions(-) create mode 100644 packages/dart/npt_flutter/lib/features/onboarding/util/onboarding_util.dart diff --git a/packages/dart/npt_flutter/lib/features/onboarding/util/onboarding_util.dart b/packages/dart/npt_flutter/lib/features/onboarding/util/onboarding_util.dart new file mode 100644 index 000000000..623904723 --- /dev/null +++ b/packages/dart/npt_flutter/lib/features/onboarding/util/onboarding_util.dart @@ -0,0 +1,33 @@ +import 'dart:async'; +import 'package:at_onboarding_flutter/at_onboarding_flutter.dart'; +import 'package:at_onboarding_flutter/at_onboarding_services.dart' show AtKeysFileUploadService, FileUploadStatus; +import 'package:at_server_status/at_server_status.dart'; + +// These types are returned from methods in this class so exports are provided for ease of use +export 'package:at_onboarding_flutter/at_onboarding_services.dart' show FileUploadStatus; +export 'package:at_server_status/at_server_status.dart' show AtStatus; + +class NoPortsOnboardingUtil { + /// The upload service will be created when the first time [uploadAtKeysFile] is called + AtKeysFileUploadService? _uploadService; + AtServerStatus? _atServerStatus; + AtOnboardingConfig config; + NoPortsOnboardingUtil(this.config); + + /// A method to check whether an atSign has been activated or not + Future atServerStatus(String atSign) async { + _atServerStatus ??= + AtStatusImpl(rootUrl: config.atClientPreference.rootDomain, rootPort: config.atClientPreference.rootPort); + return _atServerStatus!.get(atSign); + } + + /// Upload an atKeys file, returning a stream with the progress so we can update the ui accordingly. + /// Example implementation: + /// https://github.com/atsign-foundation/at_widgets/blob/b4006854fa93c21eeb5bcea41044787bdf0f6f32/packages/at_onboarding_flutter/lib/src/screen/at_onboarding_home_screen.dart#L659 + Stream uploadAtKeysFile(String? atSign) { + _uploadService ??= AtKeysFileUploadService(config: config); + return _uploadService!.uploadKeyFile(atSign); + } + + // TODO: implement APKAM onboarding +} diff --git a/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart b/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart index 84ce6704a..6c59070ec 100644 --- a/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart +++ b/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart @@ -1,18 +1,20 @@ import 'dart:developer'; -import 'dart:io'; import 'package:at_contacts_flutter/at_contacts_flutter.dart'; import 'package:at_onboarding_flutter/at_onboarding_flutter.dart'; -import 'package:at_onboarding_flutter/screen/at_onboarding_home_screen.dart'; +import 'package:at_onboarding_flutter/at_onboarding_screens.dart'; +import 'package:at_onboarding_flutter/at_onboarding_services.dart'; +import 'package:at_server_status/at_server_status.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:npt_flutter/app.dart'; import 'package:npt_flutter/constants.dart'; import 'package:npt_flutter/features/onboarding/onboarding.dart'; import 'package:npt_flutter/features/onboarding/util/atsign_manager.dart'; +import 'package:npt_flutter/features/onboarding/util/onboarding_util.dart'; import 'package:npt_flutter/features/onboarding/widgets/onboarding_dialog.dart'; import 'package:npt_flutter/routes.dart'; -import 'package:npt_flutter/util/language.dart'; import 'package:path_provider/path_provider.dart'; import 'package:phosphor_flutter/phosphor_flutter.dart'; @@ -38,7 +40,59 @@ class OnboardingButton extends StatefulWidget { } class _OnboardingButtonState extends State { - Future onboard({String? atsign, required String rootDomain, bool isFromInitState = false}) async { + // + BuildContext get appContext => App.navState.currentContext!; + + // TODO: when an atSign is being onboarded + // make this button go into a loading state or show some visual indication + // for progress for the loading screen + @override + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context)!; + return ElevatedButton.icon( + onPressed: () async { + bool shouldOnboard = await selectAtsign(); + if (shouldOnboard && context.mounted) { + var atsignInformation = context.read().state; + onboard(atsign: atsignInformation.atSign, rootDomain: atsignInformation.rootDomain); + } + }, + icon: PhosphorIcon(PhosphorIcons.arrowUpRight()), + label: Text( + strings.getStarted, + ), + iconAlignment: IconAlignment.end, + ); + } + + Future selectAtsign() async { + var options = await getAtsignEntries(); + if (!mounted) return false; + + final cubit = context.read(); + String atsign = cubit.state.atSign; + String? rootDomain = cubit.state.rootDomain; + + if (options.isEmpty) { + atsign = ""; + } else if (atsign.isEmpty) { + atsign = options.keys.first; + } + if (options.keys.contains(atsign)) { + rootDomain = options[atsign]?.rootDomain; + } else { + rootDomain = Constants.getRootDomains(context).keys.first; + } + + cubit.setState(atSign: atsign, rootDomain: rootDomain); + final results = await showDialog( + context: context, + builder: (BuildContext context) => OnboardingDialog(options: options), + ); + return results ?? false; + } + + Future onboard({required String atsign, required String rootDomain, bool isFromInitState = false}) async { var atSigns = await KeyChainManager.getInstance().getAtSignListFromKeychain(); var config = AtOnboardingConfig( atClientPreference: await loadAtClientPreference(rootDomain), @@ -47,120 +101,186 @@ class _OnboardingButtonState extends State { appAPIKey: Constants.appAPIKey, ); + var util = NoPortsOnboardingUtil(config); AtOnboardingResult? onboardingResult; - if (!atSigns.contains(atsign)) { - // This is a hack. - // Ideally it should be possible to skip the home screen in onboarding - // and go straight to either of the following (based on current atSign status): - // A) opening the file picker - // B) activating the atSign - // But unfortunately that code is SO coupled to the widget that it is really - // not worth the effort to fix right now. - // - // Assumptions made in at_onboarding_flutter which have caused this problem: - // if [atsign] is non-null the atSign already exists in the keychain - // thus any atsign that isn't in the keychain isn't handled if explicitly passed... - // this means there is no edge case handling for new or unactivated atSigns - // nor for atSigns that are activated but not in the keychain... - // - // Given that we are working on new user flows, I'm not going to waste countless - // hours for this tiny UX fix - - // TODO: fix localizations - await AtOnboardingLocalizations.load(LanguageUtil.getLanguageFromLocale(Locale(Platform.localeName)).locale); - onboardingResult = await Navigator.push( - // ignore: use_build_context_synchronously - context, - MaterialPageRoute( - builder: (BuildContext context) { - return AtOnboardingHomeScreen( - config: config, - isFromIntroScreen: false, - ); - }, - ), - ); - } else { + + if (!mounted) return; + + if (atSigns.contains(atsign)) { onboardingResult = await AtOnboarding.onboard( atsign: atsign, - // ignore: use_build_context_synchronously context: context, - config: config, + config: util.config, ); + } else { + onboardingResult = await handleAtsignByStatus(atsign, util); } - if (mounted) { - switch (onboardingResult?.status ?? AtOnboardingResultStatus.cancel) { - case AtOnboardingResultStatus.success: - await initializeContactsService(rootDomain: rootDomain); - postOnboard(onboardingResult!.atsign!, rootDomain); - final result = - await saveAtsignInformation(AtsignInformation(atSign: onboardingResult.atsign!, rootDomain: rootDomain)); - log('atsign result is:$result'); - - if (mounted) { - Navigator.of(context).pushReplacementNamed(Routes.dashboard); - } - break; - case AtOnboardingResultStatus.error: - if (isFromInitState) break; + if (!mounted) return; + // FIXME: determine why SnackBars aren't being rendered in this context + // maybe related to the popped atSign selector context being dropped from + // the tree + switch (onboardingResult?.status ?? AtOnboardingResultStatus.cancel) { + case AtOnboardingResultStatus.success: + await initializeContactsService(rootDomain: rootDomain); + postOnboard(onboardingResult!.atsign!, rootDomain); + final result = await saveAtsignInformation( + AtsignInformation( + atSign: onboardingResult.atsign!, + rootDomain: rootDomain, + ), + ); + log('atsign result is:$result'); + + if (!mounted) return; + Navigator.of(context).pushReplacementNamed(Routes.dashboard); + + break; + case AtOnboardingResultStatus.error: + if (isFromInitState) break; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.red, + content: Text(AppLocalizations.of(context)!.onboardingError), + ), + ); + break; + case AtOnboardingResultStatus.cancel: + break; + } + } + + Future handleAtsignByStatus(String atsign, NoPortsOnboardingUtil util) async { + AtStatus status; + try { + status = await util.atServerStatus(atsign); + } catch (_) { + if (!mounted) return null; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.red, + // TODO: new localization string + // "Failed to retrieve the atserver status" + content: Text(AppLocalizations.of(context)!.onboardingError), + ), + ); + return null; + } + AtOnboardingResult? result; + if (!mounted) return null; + + switch (status.status()) { + // Automatically start activation with the already entered atSign + case AtSignStatus.teapot: + result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => AtOnboardingActivateScreen( + hideReferences: true, + atSign: atsign, + config: util.config, + ), + ), + ); + if (result == null && mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( backgroundColor: Colors.red, + // TODO: new localization string + // "There was an error activating your atSign, please try again later." + // "If the issue persists, please contact support" content: Text(AppLocalizations.of(context)!.onboardingError), ), ); - break; - case AtOnboardingResultStatus.cancel: - break; - } + } + case AtSignStatus.activated: + // NOTE: for now this is hard coded to do atKey file upload + // Later on, we can add the APKAM flow, and will need to make some + // UX decisions about how the user picks which they want to do + Stream statusStream = util.uploadAtKeysFile(atsign); + result = await handleFileUploadStatusStream(statusStream, atsign); + case AtSignStatus.notFound: + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.red, + // TODO: new localization string + // "The atSign you have requested, doesn't exist in this root domain" + content: Text(AppLocalizations.of(context)!.onboardingError), + ), + ); + case AtSignStatus.unavailable: + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.red, + // TODO: new localization string + // "The atSign is unavailable, make sure you have internet connectivity" + content: Text(AppLocalizations.of(context)!.onboardingError), + ), + ); + case null: // This case should never happen, treat it as an error + case AtSignStatus.error: + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.red, + // TODO: new localization string + // "Failed to retrieve the atserver status" + content: Text(AppLocalizations.of(context)!.onboardingError), + ), + ); } + return result; } - Future selectAtsign() async { - var options = await getAtsignEntries(); - if (mounted) { - final cubit = context.read(); - String atsign = cubit.state.atSign; - String? rootDomain = cubit.state.rootDomain; - - if (options.isEmpty) { - atsign = ""; - } else if (atsign.isEmpty) { - atsign = options.keys.first; - } - if (options.keys.contains(atsign)) { - rootDomain = options[atsign]?.rootDomain; - } else { - rootDomain = Constants.getRootDomains(context).keys.first; - } + Future handleFileUploadStatusStream(Stream statusStream, String atsign) async { + AtOnboardingResult? result; + await for (FileUploadStatus status in statusStream) { + /// TODO some nice progress notifications: + /// Example implementation: + /// https://github.com/atsign-foundation/at_widgets/blob/b4006854fa93c21eeb5bcea41044787bdf0f6f32/packages/at_onboarding_flutter/lib/src/screen/at_onboarding_home_screen.dart#L659 + switch (status) { + case ErrorIncorrectKeyFile(): + result = AtOnboardingResult.error( + message: "Invalid atKeys file detected", + ); + case ErrorAtSignMismatch(): + result = AtOnboardingResult.error( + message: "The atKeys file you uploaded did not match the atSign requested", + ); + case ErrorFailedFileProcessing(): + return AtOnboardingResult.error( + message: "Failed to process the atKeys file", + ); + case ErrorAtServerUnreachable(): + result = AtOnboardingResult.error( + message: "Unable to connect to the atServer, make sure you have a stable internet connection", + ); + case ErrorAuthFailed(): + result = AtOnboardingResult.error( + message: "Authentication failed", + ); + case ErrorAuthTimeout(): + result = AtOnboardingResult.error( + message: "Authentication timed out", + ); + case ErrorPairedAtsign _: + result = AtOnboardingResult.error( + message: "The atSign ${status.atSign ?? atsign} is already paired, please contact support.", + ); + // We may not want to do anything in these cases... + // If we want to show some visual indications of progress, we can + // but it's not necessary + case FilePickingInProgress(): + case FilePickingDone(): + case ProcessingAesKeyInProgress(): + case ProcessingAesKeyDone(): + break; - cubit.setState(atSign: atsign, rootDomain: rootDomain); - final results = await showDialog( - context: context, - builder: (BuildContext context) => OnboardingDialog(options: options), - ); - return results ?? false; + case FilePickingCanceled(): + return AtOnboardingResult.cancelled(); + case FileUploadAuthSuccess _: + return AtOnboardingResult.success(atsign: status.atSign ?? atsign); + } } - return false; - } - - @override - Widget build(BuildContext context) { - final strings = AppLocalizations.of(context)!; - return ElevatedButton.icon( - onPressed: () async { - bool shouldOnboard = await selectAtsign(); - if (shouldOnboard && context.mounted) { - var atsignInformation = context.read().state; - onboard(atsign: atsignInformation.atSign, rootDomain: atsignInformation.rootDomain); - } - }, - icon: PhosphorIcon(PhosphorIcons.arrowUpRight()), - label: Text( - strings.getStarted, - ), - iconAlignment: IconAlignment.end, - ); + return result; } } diff --git a/packages/dart/npt_flutter/lib/widgets/custom_text_button.dart b/packages/dart/npt_flutter/lib/widgets/custom_text_button.dart index 237249600..516e6664b 100644 --- a/packages/dart/npt_flutter/lib/widgets/custom_text_button.dart +++ b/packages/dart/npt_flutter/lib/widgets/custom_text_button.dart @@ -1,6 +1,6 @@ import 'package:at_contacts_flutter/services/contact_service.dart'; import 'package:at_onboarding_flutter/at_onboarding_flutter.dart'; -import 'package:at_onboarding_flutter/services/onboarding_service.dart'; +import 'package:at_onboarding_flutter/at_onboarding_services.dart' hide AtOnboardingConfig; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; diff --git a/packages/dart/npt_flutter/pubspec.lock b/packages/dart/npt_flutter/pubspec.lock index 9a1048c72..0b57f9fa5 100644 --- a/packages/dart/npt_flutter/pubspec.lock +++ b/packages/dart/npt_flutter/pubspec.lock @@ -171,8 +171,8 @@ packages: dependency: "direct main" description: path: "packages/at_onboarding_flutter" - ref: trunk - resolved-ref: "2a32ac2461673e0df16f5de2e24305309a8fcd95" + ref: at_onboarding_flutter_layers + resolved-ref: b4006854fa93c21eeb5bcea41044787bdf0f6f32 url: "https://github.com/atsign-foundation/at_widgets" source: git version: "6.1.9" @@ -193,7 +193,7 @@ packages: source: hosted version: "2.0.14" at_server_status: - dependency: transitive + dependency: "direct main" description: name: at_server_status sha256: "2773fa7c4377802b671f6854863214aabe8ee8cd49be87226352dd14562a5d6b" diff --git a/packages/dart/npt_flutter/pubspec.yaml b/packages/dart/npt_flutter/pubspec.yaml index 1bef2600f..9d7f37218 100644 --- a/packages/dart/npt_flutter/pubspec.yaml +++ b/packages/dart/npt_flutter/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: at_contact: ^3.0.8 at_contacts_flutter: ^4.0.15 at_onboarding_flutter: ^6.1.8 + at_server_status: ^1.0.5 at_utils: ^3.0.16 cupertino_icons: ^1.0.8 equatable: ^2.0.5 @@ -79,7 +80,7 @@ dependency_overrides: at_onboarding_flutter: git: url: https://github.com/atsign-foundation/at_widgets - ref: trunk + ref: at_onboarding_flutter_layers path: packages/at_onboarding_flutter # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is @@ -145,4 +146,4 @@ flutter_launcher_icons: image_path: "assets/logo.png" windows: generate: true - image_path: "assets/logo.png" \ No newline at end of file + image_path: "assets/logo.png" From 3e1b8df29324d4701dcbaf1bfe2b1cefabce8a81 Mon Sep 17 00:00:00 2001 From: xavierchanth Date: Wed, 20 Nov 2024 08:54:03 -0500 Subject: [PATCH 2/6] feat: button status for onboarding --- .../onboarding/widgets/onboarding_button.dart | 137 +++++++++--------- 1 file changed, 69 insertions(+), 68 deletions(-) diff --git a/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart b/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart index 6c59070ec..061a87f16 100644 --- a/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart +++ b/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart @@ -39,9 +39,14 @@ class OnboardingButton extends StatefulWidget { State createState() => _OnboardingButtonState(); } +enum _OnboardingButtonStatus { + ready, + picking, + processingFile, +} + class _OnboardingButtonState extends State { - // - BuildContext get appContext => App.navState.currentContext!; + _OnboardingButtonStatus buttonStatus = _OnboardingButtonStatus.ready; // TODO: when an atSign is being onboarded // make this button go into a loading state or show some visual indication @@ -49,20 +54,24 @@ class _OnboardingButtonState extends State { @override Widget build(BuildContext context) { final strings = AppLocalizations.of(context)!; - return ElevatedButton.icon( - onPressed: () async { - bool shouldOnboard = await selectAtsign(); - if (shouldOnboard && context.mounted) { - var atsignInformation = context.read().state; - onboard(atsign: atsignInformation.atSign, rootDomain: atsignInformation.rootDomain); - } - }, - icon: PhosphorIcon(PhosphorIcons.arrowUpRight()), - label: Text( - strings.getStarted, - ), - iconAlignment: IconAlignment.end, - ); + return switch (buttonStatus) { + _OnboardingButtonStatus.ready => ElevatedButton.icon( + onPressed: () async { + bool shouldOnboard = await selectAtsign(); + if (shouldOnboard && context.mounted) { + var atsignInformation = context.read().state; + onboard(atsign: atsignInformation.atSign, rootDomain: atsignInformation.rootDomain); + } + }, + icon: PhosphorIcon(PhosphorIcons.arrowUpRight()), + label: Text( + strings.getStarted, + ), + iconAlignment: IconAlignment.end, + ), + _OnboardingButtonStatus.picking => const Text("Waiting for file to be picked"), + _OnboardingButtonStatus.processingFile => const Text("Processing file"), + }; } Future selectAtsign() async { @@ -141,7 +150,9 @@ class _OnboardingButtonState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( backgroundColor: Colors.red, - content: Text(AppLocalizations.of(context)!.onboardingError), + content: Text( + onboardingResult?.message ?? AppLocalizations.of(context)!.onboardingError, + ), ), ); break; @@ -155,16 +166,9 @@ class _OnboardingButtonState extends State { try { status = await util.atServerStatus(atsign); } catch (_) { - if (!mounted) return null; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - backgroundColor: Colors.red, - // TODO: new localization string - // "Failed to retrieve the atserver status" - content: Text(AppLocalizations.of(context)!.onboardingError), - ), + return AtOnboardingResult.error( + message: "Failed to retrieve the atserver status", ); - return null; } AtOnboardingResult? result; if (!mounted) return null; @@ -182,17 +186,10 @@ class _OnboardingButtonState extends State { ), ), ); - if (result == null && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - backgroundColor: Colors.red, - // TODO: new localization string - // "There was an error activating your atSign, please try again later." - // "If the issue persists, please contact support" - content: Text(AppLocalizations.of(context)!.onboardingError), - ), - ); - } + result ??= AtOnboardingResult.error( + message: "There was an error activating your atSign, please try again later.\n" + "If the issue persists, please contact support", + ); case AtSignStatus.activated: // NOTE: for now this is hard coded to do atKey file upload // Later on, we can add the APKAM flow, and will need to make some @@ -200,32 +197,17 @@ class _OnboardingButtonState extends State { Stream statusStream = util.uploadAtKeysFile(atsign); result = await handleFileUploadStatusStream(statusStream, atsign); case AtSignStatus.notFound: - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - backgroundColor: Colors.red, - // TODO: new localization string - // "The atSign you have requested, doesn't exist in this root domain" - content: Text(AppLocalizations.of(context)!.onboardingError), - ), + result = AtOnboardingResult.error( + message: "The atSign you have requested, doesn't exist in this root domain", ); case AtSignStatus.unavailable: - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - backgroundColor: Colors.red, - // TODO: new localization string - // "The atSign is unavailable, make sure you have internet connectivity" - content: Text(AppLocalizations.of(context)!.onboardingError), - ), + result = AtOnboardingResult.error( + message: "The atSign is unavailable, make sure you have internet connectivity", ); case null: // This case should never happen, treat it as an error case AtSignStatus.error: - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - backgroundColor: Colors.red, - // TODO: new localization string - // "Failed to retrieve the atserver status" - content: Text(AppLocalizations.of(context)!.onboardingError), - ), + result = AtOnboardingResult.error( + message: "Failed to retrieve the atserver status", ); } return result; @@ -233,54 +215,73 @@ class _OnboardingButtonState extends State { Future handleFileUploadStatusStream(Stream statusStream, String atsign) async { AtOnboardingResult? result; + outer: await for (FileUploadStatus status in statusStream) { - /// TODO some nice progress notifications: - /// Example implementation: - /// https://github.com/atsign-foundation/at_widgets/blob/b4006854fa93c21eeb5bcea41044787bdf0f6f32/packages/at_onboarding_flutter/lib/src/screen/at_onboarding_home_screen.dart#L659 + // Don't return from inside this switch other wise the buttonStatus + // won't be reset to ready state switch (status) { case ErrorIncorrectKeyFile(): result = AtOnboardingResult.error( message: "Invalid atKeys file detected", ); + break outer; case ErrorAtSignMismatch(): result = AtOnboardingResult.error( message: "The atKeys file you uploaded did not match the atSign requested", ); + break outer; case ErrorFailedFileProcessing(): - return AtOnboardingResult.error( + result = AtOnboardingResult.error( message: "Failed to process the atKeys file", ); + break outer; case ErrorAtServerUnreachable(): result = AtOnboardingResult.error( message: "Unable to connect to the atServer, make sure you have a stable internet connection", ); + break outer; case ErrorAuthFailed(): result = AtOnboardingResult.error( message: "Authentication failed", ); + break outer; case ErrorAuthTimeout(): result = AtOnboardingResult.error( message: "Authentication timed out", ); + break outer; case ErrorPairedAtsign _: result = AtOnboardingResult.error( message: "The atSign ${status.atSign ?? atsign} is already paired, please contact support.", ); - // We may not want to do anything in these cases... - // If we want to show some visual indications of progress, we can - // but it's not necessary + break outer; case FilePickingInProgress(): - case FilePickingDone(): + setState(() { + buttonStatus = _OnboardingButtonStatus.picking; + }); + break; // don't break outer, this is a mid progress update case ProcessingAesKeyInProgress(): + setState(() { + buttonStatus = _OnboardingButtonStatus.processingFile; + }); + break; // don't break outer, this is a mid progress update + + // We don't really need to handle these + case FilePickingDone(): case ProcessingAesKeyDone(): break; case FilePickingCanceled(): - return AtOnboardingResult.cancelled(); + result = AtOnboardingResult.cancelled(); + break outer; case FileUploadAuthSuccess _: - return AtOnboardingResult.success(atsign: status.atSign ?? atsign); + result = AtOnboardingResult.success(atsign: status.atSign ?? atsign); + break outer; } } + setState(() { + buttonStatus = _OnboardingButtonStatus.ready; + }); return result; } } From a47a58f966e6c59276e270029395d29cfdf73dee Mon Sep 17 00:00:00 2001 From: xavierchanth Date: Fri, 22 Nov 2024 14:49:22 -0500 Subject: [PATCH 3/6] feat: working onboarding flow --- packages/dart/npt_flutter/lib/constants.dart | 19 +- .../features/favorite/bloc/favorite_bloc.dart | 14 +- .../onboarding/util/activate_util.dart | 141 ++++++++++++++ .../onboarding/util/post_onboard.dart | 2 +- .../widgets/activate_atsign_dialog.dart | 175 ++++++++++++++++++ .../onboarding/widgets/onboarding_button.dart | 96 +++++++--- .../lib/widgets/custom_text_button.dart | 5 +- packages/dart/npt_flutter/pubspec.lock | 18 +- packages/dart/npt_flutter/pubspec.yaml | 5 + 9 files changed, 430 insertions(+), 45 deletions(-) create mode 100644 packages/dart/npt_flutter/lib/features/onboarding/util/activate_util.dart create mode 100644 packages/dart/npt_flutter/lib/features/onboarding/widgets/activate_atsign_dialog.dart diff --git a/packages/dart/npt_flutter/lib/constants.dart b/packages/dart/npt_flutter/lib/constants.dart index 2adbfd12b..063c746cc 100644 --- a/packages/dart/npt_flutter/lib/constants.dart +++ b/packages/dart/npt_flutter/lib/constants.dart @@ -1,10 +1,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart' show dotenv; class Constants { + static bool dotenvLoaded = false; + static Future loadDotenv() async { + if (dotenvLoaded) return; + try { + await dotenv.load(); + dotenvLoaded = true; + } catch (_) { + dotenvLoaded = false; + } + } + static String? get namespace => 'noports'; - // TODO: issue & secure API key properly - static String? get appAPIKey => 'asdf'; + + static Future get appAPIKey async { + await loadDotenv(); + return dotenv.env["APP_API_KEY"]; + } static const pngIconDark = 'assets/noports-icon64-dark.png'; static const icoIconDark = 'assets/noports-icon64-dark.ico'; diff --git a/packages/dart/npt_flutter/lib/features/favorite/bloc/favorite_bloc.dart b/packages/dart/npt_flutter/lib/features/favorite/bloc/favorite_bloc.dart index 1f6bd41ce..d0c74fce1 100644 --- a/packages/dart/npt_flutter/lib/features/favorite/bloc/favorite_bloc.dart +++ b/packages/dart/npt_flutter/lib/features/favorite/bloc/favorite_bloc.dart @@ -17,8 +17,7 @@ class FavoriteBloc extends LoggingBloc { void clearAll() => emit(const FavoritesInitial()); - FutureOr _onLoad( - FavoriteLoadEvent event, Emitter emit) async { + FutureOr _onLoad(FavoriteLoadEvent event, Emitter emit) async { emit(const FavoritesLoading()); Map? favs; @@ -35,8 +34,7 @@ class FavoriteBloc extends LoggingBloc { emit(FavoritesLoaded(favs.values)); } - FutureOr _onAdd( - FavoriteAddEvent event, Emitter emit) async { + FutureOr _onAdd(FavoriteAddEvent event, Emitter emit) async { if (state is! FavoritesLoaded) { return; } @@ -49,17 +47,13 @@ class FavoriteBloc extends LoggingBloc { } catch (_) {} } - FutureOr _onRemove( - FavoriteRemoveEvent event, Emitter emit) async { + FutureOr _onRemove(FavoriteRemoveEvent event, Emitter emit) async { if (state is! FavoritesLoaded) { return; } emit(FavoritesLoaded( - (state as FavoritesLoaded) - .favorites - .toSet() - .difference(event.toRemove.toSet()), + (state as FavoritesLoaded).favorites.toSet().difference(event.toRemove.toSet()), )); try { diff --git a/packages/dart/npt_flutter/lib/features/onboarding/util/activate_util.dart b/packages/dart/npt_flutter/lib/features/onboarding/util/activate_util.dart new file mode 100644 index 000000000..5f2a546d9 --- /dev/null +++ b/packages/dart/npt_flutter/lib/features/onboarding/util/activate_util.dart @@ -0,0 +1,141 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:at_auth/at_auth.dart'; +import 'package:at_onboarding_flutter/at_onboarding_flutter.dart' hide Response; +import 'package:at_onboarding_flutter/at_onboarding_services.dart'; +// ignore: implementation_imports +import 'package:at_onboarding_flutter/src/utils/at_onboarding_response_status.dart'; +import 'package:at_server_status/at_server_status.dart'; +import 'package:http/http.dart'; +import 'package:http/io_client.dart'; + +// Type returned from a method below +export 'package:at_onboarding_flutter/src/utils/at_onboarding_response_status.dart'; + +const apiBase = '/api/app/v3'; + +enum NoPortsActivateApiEndpoints { + login('$apiBase/authenticate/atsign'), + validate('$apiBase/authenticate/atsign/activate'); + + final String path; + const NoPortsActivateApiEndpoints(this.path); +} + +class ActivateUtil { + final String registrarUrl; + final String apiKey; + late final IOClient _http; + + ActivateUtil({required this.registrarUrl, required this.apiKey}) { + var innerClient = HttpClient(); + innerClient.badCertificateCallback = (X509Certificate cert, String host, int port) => true; + _http = IOClient(); + } + + Future registrarApiRequest(NoPortsActivateApiEndpoints endpoint, Map data) async { + Uri url = Uri.https(registrarUrl, endpoint.path); + + return _http.post( + url, + body: jsonEncode(data), + headers: { + 'Authorization': apiKey, + 'Content-Type': 'application/json', + }, + ); + } + + Future<({String? cramkey, String? errorMessage})> verifyActivation( + {required String atsign, required String otp}) async { + var res = await registrarApiRequest( + NoPortsActivateApiEndpoints.validate, + { + 'atsign': atsign, + 'otp': otp, + }, + ); + if (res.statusCode != 200) { + return ( + errorMessage: AtOnboardingLocalizations.current.error_server_unavailable, + cramkey: null, + ); + } + var payload = jsonDecode(res.body); + if (payload["message"] != "Verified") { + // The toString is for typesafety & to prevent unexpected crashes + return (errorMessage: payload["message"].toString(), cramkey: null); + } + String cramkey = payload["cramkey"]?.split(':').last ?? ''; + return (cramkey: cramkey, errorMessage: null); + } + + Future onboardFromCramKey({ + required String atsign, + required String cramkey, + required AtOnboardingConfig config, + }) async { + try { + atsign = atsign.startsWith('@') ? atsign : '@$atsign'; + OnboardingService onboardingService = OnboardingService.getInstance(); + bool isExist = await onboardingService.isExistingAtsign(atsign); + if (isExist) { + return AtOnboardingResult.error( + message: AtOnboardingLocalizations.current.error_atSign_activated, + ); + } + + //Delay for waiting for ServerStatus change to teapot when activating an atsign + await Future.delayed(const Duration(seconds: 10)); + + config.atClientPreference.cramSecret = cramkey; + onboardingService.setAtClientPreference = config.atClientPreference; + + onboardingService.setAtsign = atsign; + AtOnboardingRequest req = AtOnboardingRequest(atsign); + var res = await onboardingService.onboard( + cramSecret: cramkey, + atOnboardingRequest: req, + ); + + if (res) { + int round = 1; + ServerStatus? atSignStatus = await onboardingService.checkAtSignServerStatus(atsign); + while (atSignStatus != ServerStatus.activated) { + if (round > 10) { + break; + } + await Future.delayed(const Duration(seconds: 3)); + round++; + atSignStatus = await onboardingService.checkAtSignServerStatus(atsign); + } + + if (atSignStatus == ServerStatus.teapot) { + return AtOnboardingResult.error( + message: AtOnboardingLocalizations.current.msg_atSign_unreachable, + ); + } else if (atSignStatus == ServerStatus.activated) { + return AtOnboardingResult.success(atsign: atsign); + } + } + + return AtOnboardingResult.error(message: AtOnboardingLocalizations.current.error_authenticated_failed); + } catch (e) { + if (e == AtOnboardingResponseStatus.authFailed) { + return AtOnboardingResult.error( + message: AtOnboardingLocalizations.current.error_authenticated_failed, + ); + } else if (e == AtOnboardingResponseStatus.serverNotReached) { + return AtOnboardingResult.error( + message: AtOnboardingLocalizations.current.msg_atSign_unreachable, + ); + } else if (e == AtOnboardingResponseStatus.timeOut) { + return AtOnboardingResult.error( + message: AtOnboardingLocalizations.current.msg_response_time_out, + ); + } + return AtOnboardingResult.error(message: e.toString()); + } + } +} diff --git a/packages/dart/npt_flutter/lib/features/onboarding/util/post_onboard.dart b/packages/dart/npt_flutter/lib/features/onboarding/util/post_onboard.dart index 4e9ebeee8..baa288ea7 100644 --- a/packages/dart/npt_flutter/lib/features/onboarding/util/post_onboard.dart +++ b/packages/dart/npt_flutter/lib/features/onboarding/util/post_onboard.dart @@ -9,7 +9,7 @@ Future postOnboard(String atSign, String rootDomain) async { status: OnboardingStatus.onboarded, ); // Start loading application data in the background as soon as we have an atClient + App.navState.currentContext?.read().add(const FavoriteLoadEvent()); App.navState.currentContext?.read().add(const ProfileListLoadEvent()); App.navState.currentContext?.read().add(const SettingsLoadEvent()); - App.navState.currentContext?.read().add(const FavoriteLoadEvent()); } diff --git a/packages/dart/npt_flutter/lib/features/onboarding/widgets/activate_atsign_dialog.dart b/packages/dart/npt_flutter/lib/features/onboarding/widgets/activate_atsign_dialog.dart new file mode 100644 index 000000000..41c73775c --- /dev/null +++ b/packages/dart/npt_flutter/lib/features/onboarding/widgets/activate_atsign_dialog.dart @@ -0,0 +1,175 @@ +import 'dart:convert'; + +import 'package:at_onboarding_flutter/at_onboarding_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:npt_flutter/features/onboarding/util/activate_util.dart'; +import 'package:pin_code_fields/pin_code_fields.dart'; + +class ActivateAtsignDialog extends StatefulWidget { + final pinLength = 4; + final String registrarUrl; + final String apiKey; + final String atSign; + final AtOnboardingConfig config; + const ActivateAtsignDialog({ + super.key, + required this.atSign, + required this.apiKey, + required this.config, + required this.registrarUrl, + }); + + @override + State createState() => _ActivateAtsignDialogState(); +} + +enum ActivationStatus { + preparing, // contacting the registrar to send an OTP + otpWait, // Waiting for user to enter OTP + activating, // OTP received, trying to activate +} + +class _ActivateAtsignDialogState extends State { + late final ActivateUtil util; + ActivationStatus status = ActivationStatus.preparing; + TextEditingController pinController = TextEditingController(); + + @override + void initState() { + super.initState(); + util = ActivateUtil( + registrarUrl: widget.registrarUrl, + apiKey: widget.apiKey, + ); + _getPinCode(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + // TODO localize + title: const Text("Activate your atsign"), + content: switch (status) { + // TODO localize + ActivationStatus.preparing => const Text( + "Preparing your text for activation", + ), + ActivationStatus.otpWait => SizedBox( + height: 90, + child: Column( + children: [ + // TODO localize + const Text("Please enter the OTP from your email"), + PinCodeTextField( + appContext: context, + length: widget.pinLength, + controller: pinController, + onChanged: (value) { + setState(() { + pinController.text = value.toUpperCase(); + }); + }, + ), + ], + ), + ), + ActivationStatus.activating => const Text( + // TODO localize + "Activating your atSign", + ), + }, + actions: switch (status) { + ActivationStatus.preparing => [cancelButton], + ActivationStatus.otpWait => [cancelButton, resendPinButton, confirmPinButton], + // Don't allow the user to cancel activate as this opens up a bunch of + // edge cases around navigation and onboarding state + ActivationStatus.activating => [], + }, + ); + } + + Future _getPinCode() async { + var res = await util.registrarApiRequest( + NoPortsActivateApiEndpoints.login, + {'atsign': widget.atSign}, + ); + + if (res.statusCode == 200 && jsonDecode(res.body)["message"] == "Sent Successfully") { + setState(() { + status = ActivationStatus.otpWait; + }); + } else { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + backgroundColor: Colors.red, + // TODO localize + content: Text( + "Failed to request an OTP, try resending, or contact support if the issue persists", + ), + ), + ); + } + } + + Widget get cancelButton => TextButton( + key: const Key("NoPortsActivateCancelButton"), + // TODO localize + child: const Text("Cancel"), + onPressed: () { + Navigator.of(context).pop(AtOnboardingResult.cancelled()); + }, + ); + + Widget get resendPinButton => TextButton( + key: const Key("NoPortsActivateResendButton"), + onPressed: _getPinCode, + // TODO localize + child: const Text("Resend Pin"), + ); + + Widget get confirmPinButton => TextButton( + key: const Key("NoPortsActivateConfirmButton"), + onPressed: pinController.text.length < 4 + ? null // disable the button when pin isn't complete + : () async { + setState(() { + status = ActivationStatus.activating; + }); + + var (:cramkey, :errorMessage) = await util.verifyActivation( + atsign: widget.atSign, + otp: pinController.text, + ); + + if (cramkey == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + backgroundColor: Colors.red, + content: Text( + // TODO localize + "Failed to verify the OTP with the activation server, please try again. Contact support if the issue persists", + ), + ), + ); + setState(() { + pinController = TextEditingController(); // controller was disposed, make a new one + status = ActivationStatus.otpWait; + }); + return; + } + // TODO cram key is set, why is authentication failing + + var result = await util.onboardFromCramKey( + atsign: widget.atSign, + cramkey: cramkey, + config: widget.config, + ); + + if (!mounted) return; + Navigator.of(context).pop(result); + }, + // TODO localize + child: const Text("Confirm"), + ); +} diff --git a/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart b/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart index 061a87f16..bf4f4dc76 100644 --- a/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart +++ b/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart @@ -4,15 +4,17 @@ import 'package:at_contacts_flutter/at_contacts_flutter.dart'; import 'package:at_onboarding_flutter/at_onboarding_flutter.dart'; import 'package:at_onboarding_flutter/at_onboarding_screens.dart'; import 'package:at_onboarding_flutter/at_onboarding_services.dart'; +// ignore: implementation_imports +import 'package:at_onboarding_flutter/src/utils/at_onboarding_app_constants.dart'; import 'package:at_server_status/at_server_status.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:npt_flutter/app.dart'; import 'package:npt_flutter/constants.dart'; import 'package:npt_flutter/features/onboarding/onboarding.dart'; import 'package:npt_flutter/features/onboarding/util/atsign_manager.dart'; import 'package:npt_flutter/features/onboarding/util/onboarding_util.dart'; +import 'package:npt_flutter/features/onboarding/widgets/activate_atsign_dialog.dart'; import 'package:npt_flutter/features/onboarding/widgets/onboarding_dialog.dart'; import 'package:npt_flutter/routes.dart'; @@ -69,7 +71,9 @@ class _OnboardingButtonState extends State { ), iconAlignment: IconAlignment.end, ), + // TODO: localize _OnboardingButtonStatus.picking => const Text("Waiting for file to be picked"), + // TODO: localize _OnboardingButtonStatus.processingFile => const Text("Processing file"), }; } @@ -103,11 +107,12 @@ class _OnboardingButtonState extends State { Future onboard({required String atsign, required String rootDomain, bool isFromInitState = false}) async { var atSigns = await KeyChainManager.getInstance().getAtSignListFromKeychain(); + var apiKey = await Constants.appAPIKey; var config = AtOnboardingConfig( atClientPreference: await loadAtClientPreference(rootDomain), rootEnvironment: RootEnvironment.Production, domain: rootDomain, - appAPIKey: Constants.appAPIKey, + appAPIKey: apiKey, ); var util = NoPortsOnboardingUtil(config); @@ -124,11 +129,10 @@ class _OnboardingButtonState extends State { } else { onboardingResult = await handleAtsignByStatus(atsign, util); } - + setState(() { + buttonStatus = _OnboardingButtonStatus.ready; + }); if (!mounted) return; - // FIXME: determine why SnackBars aren't being rendered in this context - // maybe related to the popped atSign selector context being dropped from - // the tree switch (onboardingResult?.status ?? AtOnboardingResultStatus.cancel) { case AtOnboardingResultStatus.success: await initializeContactsService(rootDomain: rootDomain); @@ -167,7 +171,8 @@ class _OnboardingButtonState extends State { status = await util.atServerStatus(atsign); } catch (_) { return AtOnboardingResult.error( - message: "Failed to retrieve the atserver status", + // TODO localize + message: "Failed to retrieve the atserver status, make sure you have a stable internet connection", ); } AtOnboardingResult? result; @@ -176,20 +181,53 @@ class _OnboardingButtonState extends State { switch (status.status()) { // Automatically start activation with the already entered atSign case AtSignStatus.teapot: - result = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => AtOnboardingActivateScreen( - hideReferences: true, - atSign: atsign, - config: util.config, - ), + final apiKey = await Constants.appAPIKey; + + if (apiKey == null) { + result = AtOnboardingResult.error( + // TODO localize + message: "The atSign you have requested, doesn't exist in this root domain", + ); + break; + } + AtOnboardingConstants.setApiKey(apiKey); + AtOnboardingConstants.rootDomain = util.config.atClientPreference.rootDomain; + // TODO: localize locale - right now hardcoded to english + await AtOnboardingLocalizations.load(const Locale("en")); + if (!mounted) return null; + Map apis = { + "root.atsign.org": "my.atsign.com", + "root.atsign.wtf": "my.atsign.wtf", + }; + var regUrl = apis[util.config.atClientPreference.rootDomain]; + if (regUrl == null) { + result ??= AtOnboardingResult.error( + // TODO: localize + message: "The specified root domain is not supported by automatic activation.", + ); + break; + } + result = await showDialog( + context: context, + builder: (context) => ActivateAtsignDialog( + atSign: atsign, + apiKey: apiKey, + config: util.config, + registrarUrl: regUrl, ), ); - result ??= AtOnboardingResult.error( - message: "There was an error activating your atSign, please try again later.\n" - "If the issue persists, please contact support", - ); + + if (result is AtOnboardingResult) { + //Update primary atsign after onboard success + if (result.status == AtOnboardingResultStatus.success && result.atsign != null) { + var onboardingService = OnboardingService.getInstance(); + bool res = await onboardingService.changePrimaryAtsign(atsign: result.atsign!); + if (!res) { + result = AtOnboardingResult.error(message: "Failed to switch atSigns after activation"); + } + } + } + // TODO: finalize onboarding case AtSignStatus.activated: // NOTE: for now this is hard coded to do atKey file upload // Later on, we can add the APKAM flow, and will need to make some @@ -198,15 +236,19 @@ class _OnboardingButtonState extends State { result = await handleFileUploadStatusStream(statusStream, atsign); case AtSignStatus.notFound: result = AtOnboardingResult.error( + // TODO: localize message: "The atSign you have requested, doesn't exist in this root domain", ); case AtSignStatus.unavailable: result = AtOnboardingResult.error( - message: "The atSign is unavailable, make sure you have internet connectivity", + // TODO: localize + message: "The atSign is unavailable. Make sure you have pressed \"Activate\" from your dashboard " + "and have a stable internet connection.", ); case null: // This case should never happen, treat it as an error case AtSignStatus.error: result = AtOnboardingResult.error( + // TODO: localize message: "Failed to retrieve the atserver status", ); } @@ -222,36 +264,43 @@ class _OnboardingButtonState extends State { switch (status) { case ErrorIncorrectKeyFile(): result = AtOnboardingResult.error( + // TODO: localize message: "Invalid atKeys file detected", ); break outer; case ErrorAtSignMismatch(): result = AtOnboardingResult.error( + // TODO: localize message: "The atKeys file you uploaded did not match the atSign requested", ); break outer; case ErrorFailedFileProcessing(): result = AtOnboardingResult.error( + // TODO: localize message: "Failed to process the atKeys file", ); break outer; case ErrorAtServerUnreachable(): result = AtOnboardingResult.error( + // TODO: localize message: "Unable to connect to the atServer, make sure you have a stable internet connection", ); break outer; case ErrorAuthFailed(): result = AtOnboardingResult.error( + // TODO: localize message: "Authentication failed", ); break outer; case ErrorAuthTimeout(): result = AtOnboardingResult.error( + // TODO: localize message: "Authentication timed out", ); break outer; case ErrorPairedAtsign _: result = AtOnboardingResult.error( + // TODO: localize message: "The atSign ${status.atSign ?? atsign} is already paired, please contact support.", ); break outer; @@ -259,12 +308,12 @@ class _OnboardingButtonState extends State { setState(() { buttonStatus = _OnboardingButtonStatus.picking; }); - break; // don't break outer, this is a mid progress update + break; case ProcessingAesKeyInProgress(): setState(() { buttonStatus = _OnboardingButtonStatus.processingFile; }); - break; // don't break outer, this is a mid progress update + break; // We don't really need to handle these case FilePickingDone(): @@ -279,9 +328,6 @@ class _OnboardingButtonState extends State { break outer; } } - setState(() { - buttonStatus = _OnboardingButtonStatus.ready; - }); return result; } } diff --git a/packages/dart/npt_flutter/lib/widgets/custom_text_button.dart b/packages/dart/npt_flutter/lib/widgets/custom_text_button.dart index 516e6664b..dde1e5e01 100644 --- a/packages/dart/npt_flutter/lib/widgets/custom_text_button.dart +++ b/packages/dart/npt_flutter/lib/widgets/custom_text_button.dart @@ -1,6 +1,6 @@ import 'package:at_contacts_flutter/services/contact_service.dart'; import 'package:at_onboarding_flutter/at_onboarding_flutter.dart'; -import 'package:at_onboarding_flutter/at_onboarding_services.dart' hide AtOnboardingConfig; +import 'package:at_onboarding_flutter/at_onboarding_services.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -128,6 +128,7 @@ class CustomTextButton extends StatelessWidget { break; case CustomListTileType.resetAtsign: final futurePreference = await loadAtClientPreference(rootDomain!); + final apiKey = await Constants.appAPIKey; if (context.mounted) { final result = await AtOnboarding.reset( context: context, @@ -135,7 +136,7 @@ class CustomTextButton extends StatelessWidget { atClientPreference: futurePreference, rootEnvironment: RootEnvironment.Testing, domain: rootDomain, - appAPIKey: Constants.appAPIKey, + appAPIKey: apiKey, ), ); final OnboardingService onboardingService = OnboardingService.getInstance(); diff --git a/packages/dart/npt_flutter/pubspec.lock b/packages/dart/npt_flutter/pubspec.lock index f1f3372cc..63bb02684 100644 --- a/packages/dart/npt_flutter/pubspec.lock +++ b/packages/dart/npt_flutter/pubspec.lock @@ -64,7 +64,7 @@ packages: source: hosted version: "2.11.0" at_auth: - dependency: transitive + dependency: "direct main" description: name: at_auth sha256: f4fec32e2a1ca8827604b5e54a7611ddad092c6ba607c138675c1cba5215b038 @@ -630,6 +630,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.6" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b + url: "https://pub.dev" + source: hosted + version: "5.2.1" flutter_keychain: dependency: transitive description: @@ -734,7 +742,7 @@ packages: source: hosted version: "2.2.3" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 @@ -1101,7 +1109,7 @@ packages: source: hosted version: "2.1.0" pin_code_fields: - dependency: transitive + dependency: "direct main" description: name: pin_code_fields sha256: "4c0db7fbc889e622e7c71ea54b9ee624bb70c7365b532abea0271b17ea75b729" @@ -1549,10 +1557,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.4" + version: "14.2.5" watcher: dependency: transitive description: diff --git a/packages/dart/npt_flutter/pubspec.yaml b/packages/dart/npt_flutter/pubspec.yaml index 9d7f37218..2650d2b35 100644 --- a/packages/dart/npt_flutter/pubspec.yaml +++ b/packages/dart/npt_flutter/pubspec.yaml @@ -29,6 +29,7 @@ environment: # versions available, run `flutter pub outdated`. dependencies: adaptive_theme: ^3.6.0 + at_auth: ^2.0.7 at_client_mobile: ^3.2.18 at_contact: ^3.0.8 at_contacts_flutter: ^4.0.15 @@ -41,9 +42,11 @@ dependencies: flutter: sdk: flutter flutter_bloc: ^8.1.6 + flutter_dotenv: ^5.2.1 flutter_localizations: sdk: flutter flutter_svg: ^2.0.10+1 + http: ^1.2.2 intl: any json_annotation: ^4.9.0 json_serializable: ^6.8.0 @@ -53,6 +56,7 @@ dependencies: path: ^1.9.0 path_provider: ^2.1.4 phosphor_flutter: ^2.1.0 + pin_code_fields: ^8.0.1 socket_connector: ^2.3.3 toml: ^0.16.0 tray_manager: ^0.2.3 @@ -102,6 +106,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - assets/ + - .env # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images # For details regarding adding assets from package dependencies, see From 2a28b9cfb409a8939c9d7714c1f3cdf3acf51f6f Mon Sep 17 00:00:00 2001 From: xavierchanth Date: Fri, 22 Nov 2024 15:26:04 -0500 Subject: [PATCH 4/6] chore: cleanup activate dialog --- .../widgets/activate_atsign_dialog.dart | 91 ++++++++++++------- 1 file changed, 60 insertions(+), 31 deletions(-) diff --git a/packages/dart/npt_flutter/lib/features/onboarding/widgets/activate_atsign_dialog.dart b/packages/dart/npt_flutter/lib/features/onboarding/widgets/activate_atsign_dialog.dart index 41c73775c..c4cfacefb 100644 --- a/packages/dart/npt_flutter/lib/features/onboarding/widgets/activate_atsign_dialog.dart +++ b/packages/dart/npt_flutter/lib/features/onboarding/widgets/activate_atsign_dialog.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:at_onboarding_flutter/at_onboarding_flutter.dart'; import 'package:flutter/material.dart'; import 'package:npt_flutter/features/onboarding/util/activate_util.dart'; +import 'package:npt_flutter/widgets/spinner.dart'; import 'package:pin_code_fields/pin_code_fields.dart'; class ActivateAtsignDialog extends StatefulWidget { @@ -33,6 +34,7 @@ class _ActivateAtsignDialogState extends State { late final ActivateUtil util; ActivationStatus status = ActivationStatus.preparing; TextEditingController pinController = TextEditingController(); + FocusNode pinFocusNode = FocusNode(); @override void initState() { @@ -47,37 +49,62 @@ class _ActivateAtsignDialogState extends State { @override Widget build(BuildContext context) { return AlertDialog( - // TODO localize - title: const Text("Activate your atsign"), - content: switch (status) { - // TODO localize - ActivationStatus.preparing => const Text( - "Preparing your text for activation", - ), - ActivationStatus.otpWait => SizedBox( - height: 90, - child: Column( - children: [ - // TODO localize - const Text("Please enter the OTP from your email"), - PinCodeTextField( - appContext: context, - length: widget.pinLength, - controller: pinController, - onChanged: (value) { - setState(() { - pinController.text = value.toUpperCase(); - }); - }, - ), - ], + title: Center( + child: switch (status) { + // TODO localize + ActivationStatus.preparing => const Text("Preparing for activation"), + ActivationStatus.otpWait => const Text("Please enter the OTP from your email"), + ActivationStatus.activating => const Text("Activating"), + }, + ), + content: SizedBox( + height: 80, + width: 400, + child: switch (status) { + ActivationStatus.preparing || ActivationStatus.activating => const Spinner(), + ActivationStatus.otpWait => SizedBox( + height: 80, + child: Column( + children: [ + // TODO localize + PinCodeTextField( + focusNode: pinFocusNode, + appContext: context, + length: widget.pinLength, + controller: pinController, + onChanged: (value) { + setState(() { + pinController.text = value.toUpperCase(); + }); + }, + // Styling + animationType: AnimationType.fade, + pinTheme: PinTheme( + shape: PinCodeFieldShape.box, + borderRadius: BorderRadius.circular(5), + fieldHeight: 50, + fieldWidth: 40, + activeFillColor: Colors.white, + inactiveFillColor: Colors.white, + ), + cursorColor: Colors.black, + animationDuration: const Duration(milliseconds: 300), + enableActiveFill: true, + keyboardType: TextInputType.number, + boxShadows: const [ + BoxShadow( + offset: Offset(0, 1), + color: Colors.black12, + blurRadius: 10, + ) + ], + beforeTextPaste: (text) => true, + ), + ], + ), ), - ), - ActivationStatus.activating => const Text( - // TODO localize - "Activating your atSign", - ), - }, + }, + ), actions: switch (status) { ActivationStatus.preparing => [cancelButton], ActivationStatus.otpWait => [cancelButton, resendPinButton, confirmPinButton], @@ -110,6 +137,9 @@ class _ActivateAtsignDialogState extends State { ), ); } + if (!pinFocusNode.hasFocus) { + pinFocusNode.requestFocus(); + } } Widget get cancelButton => TextButton( @@ -158,7 +188,6 @@ class _ActivateAtsignDialogState extends State { }); return; } - // TODO cram key is set, why is authentication failing var result = await util.onboardFromCramKey( atsign: widget.atSign, From 67a603f2cfd400cad0d620264d94a797f9de4fb3 Mon Sep 17 00:00:00 2001 From: xavierchanth Date: Fri, 22 Nov 2024 15:26:17 -0500 Subject: [PATCH 5/6] fix: don't allow mid-activate cancellation --- .../lib/features/onboarding/widgets/onboarding_button.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart b/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart index bf4f4dc76..ef34f9081 100644 --- a/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart +++ b/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart @@ -209,6 +209,7 @@ class _OnboardingButtonState extends State { } result = await showDialog( context: context, + barrierDismissible: false, builder: (context) => ActivateAtsignDialog( atSign: atsign, apiKey: apiKey, From ba838e3ad5b12647b0b6c3841b192b3736afbef3 Mon Sep 17 00:00:00 2001 From: xavierchanth Date: Fri, 22 Nov 2024 15:28:06 -0500 Subject: [PATCH 6/6] fix: backup atKeys --- .../lib/widgets/custom_text_button.dart | 35 ++++++++++++++++++- packages/dart/npt_flutter/pubspec.lock | 6 ++-- packages/dart/npt_flutter/pubspec.yaml | 1 + 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/dart/npt_flutter/lib/widgets/custom_text_button.dart b/packages/dart/npt_flutter/lib/widgets/custom_text_button.dart index dde1e5e01..8ada85d79 100644 --- a/packages/dart/npt_flutter/lib/widgets/custom_text_button.dart +++ b/packages/dart/npt_flutter/lib/widgets/custom_text_button.dart @@ -1,6 +1,12 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:at_backupkey_flutter/services/backupkey_service.dart'; import 'package:at_contacts_flutter/services/contact_service.dart'; import 'package:at_onboarding_flutter/at_onboarding_flutter.dart'; import 'package:at_onboarding_flutter/at_onboarding_services.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -123,7 +129,34 @@ class CustomTextButton extends StatelessWidget { // break; case CustomListTileType.backupYourKey: if (context.mounted) { - BackupKeyWidget(atsign: ContactService().currentAtsign).showBackupDialog(context); + var atsign = context.read().getAtSign(); + // Build file data + var aesEncryptedKeys = await BackUpKeyService.getEncryptedKeys(atsign); + var keyString = jsonEncode(aesEncryptedKeys); + final List codeUnits = keyString.codeUnits; + final Uint8List data = Uint8List.fromList(codeUnits); + + // Get file path to write to + String? outputFile = await FilePicker.platform.saveFile( + dialogTitle: 'Please select a file to export to:', + fileName: '${atsign}_key.atKeys', + ); + if (outputFile == null) return; + // Create and write the file + try { + var f = File(outputFile); + await f.create(recursive: true); + await f.writeAsBytes(data); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.red, + // TODO localize + content: Text("Failed to save the atKeys file: $e"), + ), + ); + } } break; case CustomListTileType.resetAtsign: diff --git a/packages/dart/npt_flutter/pubspec.lock b/packages/dart/npt_flutter/pubspec.lock index 63bb02684..c21c06e3a 100644 --- a/packages/dart/npt_flutter/pubspec.lock +++ b/packages/dart/npt_flutter/pubspec.lock @@ -72,13 +72,13 @@ packages: source: hosted version: "2.0.7" at_backupkey_flutter: - dependency: transitive + dependency: "direct main" description: name: at_backupkey_flutter - sha256: "91425f34aabec7fc37f61259af690676aae6c1776c2feac7bfea5f7ea7fc3812" + sha256: "674d5ebf8443e0848c86cd14e25c326e33d7fef54a2a9732c4328a138f6c4fa5" url: "https://pub.dev" source: hosted - version: "4.0.15" + version: "4.0.16" at_base2e15: dependency: transitive description: diff --git a/packages/dart/npt_flutter/pubspec.yaml b/packages/dart/npt_flutter/pubspec.yaml index 2650d2b35..0c0d6861b 100644 --- a/packages/dart/npt_flutter/pubspec.yaml +++ b/packages/dart/npt_flutter/pubspec.yaml @@ -30,6 +30,7 @@ environment: dependencies: adaptive_theme: ^3.6.0 at_auth: ^2.0.7 + at_backupkey_flutter: ^4.0.16 at_client_mobile: ^3.2.18 at_contact: ^3.0.8 at_contacts_flutter: ^4.0.15