commit c7ed42b1c75c839cb9916923f569d777deb9bbad Author: Hans Kokx Date: Thu Sep 19 13:34:51 2024 +0200 Initial Signed-off-by: Hans Kokx diff --git a/README.md b/README.md new file mode 100644 index 0000000..b7a513e --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# This is probably not what you're looking for diff --git a/http_client/authorization_interceptor.dart b/http_client/authorization_interceptor.dart new file mode 100644 index 0000000..3e0a98c --- /dev/null +++ b/http_client/authorization_interceptor.dart @@ -0,0 +1,94 @@ +import "dart:developer"; + +import "package:arcane_framework/arcane_framework.dart"; +import "package:arcane_helper_utils/arcane_helper_utils.dart"; +import "package:dio/dio.dart"; + +class AuthorizationInterceptor extends Interceptor { + AuthorizationInterceptor(); + + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + if (options.path == EnvVar.accessTokenUrl.value) { + return super.onRequest(options, handler); + } + + final bool isSignedIn = Arcane.auth.isSignedIn.value; + String? accessToken; + + if (isSignedIn) accessToken = await Arcane.auth.accessToken; + + // Gather IDs for each request so we can track each HTTP request by session, + // install, and per-request. These IDs can be used to correlate requests + // between the front-end and backend in the logs. + final String requestId = IdService.I.newId; + final String? installId = IdService.I.installId; + final String? sessionId = IdService.I.sessionId.value; + + // Logs the auth token to the debug console. Useful for debugging requests + // using third-party tools where you need to authenticate. Only works if the + // auth token isn't null and [Feature.debugPrintAuthToken] is enabled. + if (accessToken != null && Feature.debugPrintAuthToken.enabled) { + log("Token expires at ${accessToken.jwtExpiryTime()!.toIso8601String()}\n\n$accessToken"); + } + + // Assembles a Map of metadata that we're going to add to each HTTP request + // so we can log out all the information in the debug console. We also + // store all of the IDs we've collected into a Map for future use. + final Map metadata = { + "request_id": requestId, + if (installId.isNotNullOrEmpty) "install_id": "$installId", + if (sessionId.isNotNullOrEmpty) "session_id": "$sessionId", + }; + + // Attempts to map the "query" parameter in a GraphQL request to a String + // so that we can log it out in the debug console. + try { + final Map query = options.data as Map; + + for (final MapEntry entry in query.entries) { + if (entry.key == "query") { + final String queryString = entry.value.toString(); + final int queryStringOriginalLength = queryString.length; + const int trimQueryStringAtLength = 64; + final int trimToLength = + queryStringOriginalLength > trimQueryStringAtLength + ? trimQueryStringAtLength + : queryStringOriginalLength; + final String finalString = + "${queryString.substring(0, trimToLength)}${trimToLength < queryStringOriginalLength ? "[...]" : ""}"; + metadata.addAll({entry.key: finalString}); + } + } + } catch (e) { + log(e.toString()); + } + + // Removes the extra query string from the headers so we're not sending it + // to the server along with our request. + metadata.removeWhere((key, value) => key == "query"); + + // Tries to add the authentication token to the header metadata that we're + // going to add to the request. + if (accessToken.isNotNullOrEmpty) { + metadata.addAll({"Authorization": "Bearer $accessToken"}); + } + + // Adds all the collected metadata (auth token, request ID, etc.) to the + // request headers. + options.headers.addAll(metadata); + + // Logs the HTTP request so we can see the request ID and truncated query + // string (if available). + Arcane.log( + "Making HTTP Request ${options.path}", + level: Level.info, + metadata: metadata, + ); + + return super.onRequest(options, handler); + } +} diff --git a/http_client/client.dart b/http_client/client.dart new file mode 100644 index 0000000..21241cf --- /dev/null +++ b/http_client/client.dart @@ -0,0 +1,92 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:dio/dio.dart"; +import "package:flutter/foundation.dart"; +import "package:gql_dio_link/gql_dio_link.dart"; +import "package:graphql/client.dart"; + +class AppHttpClient { + static bool _isMocked = false; + + static final AppHttpClient _service = AppHttpClient._internal(); + + factory AppHttpClient() { + return _service; + } + + AppHttpClient._internal(); + + static late GraphQLClient _graphQlClient; + static GraphQLClient get graphQlClient => _graphQlClient; + + static late GraphQLClient _webSocketClient; + static GraphQLClient get webSocketClient => _webSocketClient; + + @visibleForTesting + static void setMocked() => _isMocked = true; + + static Future init(Dio dio, {String? url}) async { + if (_isMocked) return; + + late final String? authToken; + + try { + authToken = await Arcane.auth.accessToken; + } catch (e) { + return; + } + + _webSocketClient = GraphQLClient( + cache: GraphQLCache(), + queryRequestTimeout: const Duration(seconds: 40), + defaultPolicies: DefaultPolicies( + query: Policies( + fetch: FetchPolicy.networkOnly, + ), + ), + link: WebSocketLink( + "wss://$url", + config: SocketClientConfig( + autoReconnect: true, + initialPayload: { + "Authorization": "Bearer $authToken", + }, + ), + subProtocol: GraphQLProtocol.graphqlTransportWs, + ), + ); + + _graphQlClient = GraphQLClient( + cache: GraphQLCache(), + queryRequestTimeout: const Duration(seconds: 40), + defaultPolicies: DefaultPolicies( + query: Policies( + fetch: FetchPolicy.networkOnly, + ), + ), + link: DioLink( + "https://$url", + client: dio, + ), + ); + } +} + +extension QueryResultExtension on QueryResult { + int get errorStatusCode { + if (exception?.linkException != null && + exception!.linkException is HttpLinkServerException) { + final HttpLinkServerException httpLinkException = + exception!.linkException as HttpLinkServerException; + return httpLinkException.response.statusCode; + } + return -1; + } + + bool get failed { + return !success; + } + + bool get success { + return !hasException && (data ?? const {}).isNotEmpty; + } +} diff --git a/http_client/dio_helper.dart b/http_client/dio_helper.dart new file mode 100644 index 0000000..62dc215 --- /dev/null +++ b/http_client/dio_helper.dart @@ -0,0 +1,63 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:dio/dio.dart"; +import "package:dio_smart_retry/dio_smart_retry.dart"; +import "package:get_it/get_it.dart"; +import "package:native_dio_adapter/native_dio_adapter.dart"; +import "package:pretty_dio_logger/pretty_dio_logger.dart"; + +import "authorization_interceptor.dart"; + +abstract class DioHelper { + DioHelper._(); + + static Dio createDioInstance(GetIt container) { + final Dio dio = Dio( + BaseOptions( + baseUrl: "https://${EnvVar.graphQlUrl.value}", + connectTimeout: const Duration(seconds: 10), + sendTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(seconds: 40), + followRedirects: false, + ), + ); + + dio.interceptors + ..add(container.get()) + ..add(dioLogger) + ..add(smartRetry(dio)); + + dio.httpClientAdapter = NativeAdapter(); + + return dio; + } + + static Interceptor smartRetry(Dio dio) => RetryInterceptor( + dio: dio, + logPrint: Arcane.log, + retries: 3, + retryDelays: const [ + Duration(seconds: 1), + Duration(seconds: 2), + Duration(seconds: 3), + ], + ); + + static PrettyDioLogger get dioLogger => PrettyDioLogger( + requestHeader: true, + requestBody: true, + responseBody: true, + responseHeader: false, + error: true, + compact: true, + maxWidth: 90, + enabled: Feature.httpLogging.enabled, + filter: (options, args) { + if (options.path.contains("/graphql")) { + return true; + } + + // don't print responses with unit8 list data + return !args.isResponse || !args.hasUint8ListData; + }, + ); +} diff --git a/interfaces/auth/amplify_cognito_interface.dart b/interfaces/auth/amplify_cognito_interface.dart new file mode 100644 index 0000000..d65b77c --- /dev/null +++ b/interfaces/auth/amplify_cognito_interface.dart @@ -0,0 +1,280 @@ +import "package:amplify_auth_cognito/amplify_auth_cognito.dart"; +import "package:amplify_flutter/amplify_flutter.dart"; +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter/widgets.dart"; + +class AmplifyInterface implements ArcaneAuthInterface { + AmplifyInterface._internal(); + + static bool _mocked = false; + + static final ArcaneAuthInterface _instance = AmplifyInterface._internal(); + static ArcaneAuthInterface get I => _instance; + + AmplifyAuthCognito get _cognito => + Amplify.Auth.getPlugin(AmplifyAuthCognito.pluginKey); + + Future get _session async { + try { + return await _cognito.fetchAuthSession(); + } on AuthException catch (_) { + return null; + } + } + + @override + Future get isSignedIn => + _session.then((value) => value?.isSignedIn == true); + + @override + Future get accessToken => isSignedIn.then( + (loggedIn) => loggedIn + ? _session.then( + (value) => value?.userPoolTokensResult.value.accessToken.raw, + ) + : null, + ); + + @override + Future get refreshToken => isSignedIn.then( + (loggedIn) => loggedIn + ? _session.then( + (value) => value?.userPoolTokensResult.value.refreshToken, + ) + : null, + ); + + @override + Future> logout() async { + final result = await _cognito.signOut(); + + if (result is CognitoFailedSignOut) { + return Result.error(result.exception.message); + } + + return Result.ok(null); + } + + @override + Future> loginWithEmailAndPassword({ + required String email, + required String password, + }) async { + final bool alreadyLoggedIn = await isSignedIn; + + if (alreadyLoggedIn) return Result.ok(null); + + try { + final CognitoSignInResult result = await _cognito.signIn( + username: email, + password: password, + ); + return await _handleSignInResult(result, email); + } on AuthException catch (e) { + return Result.error("Error signing in: ${e.message}"); + } catch (e) { + return Result.error("Error signing in: $e"); + } + } + + Future> _handleSignInResult( + SignInResult result, + String email, + ) async { + switch (result.nextStep.signInStep) { + case AuthSignInStep.confirmSignInWithSmsMfaCode: + final codeDeliveryDetails = result.nextStep.codeDeliveryDetails!; + return Result.error(_handleCodeDelivery(codeDeliveryDetails)); + case AuthSignInStep.confirmSignInWithNewPassword: + return Result.error("Enter a new password to continue signing in"); + case AuthSignInStep.confirmSignInWithCustomChallenge: + final parameters = result.nextStep.additionalInfo; + final prompt = parameters["prompt"]!; + return Result.error(prompt); + case AuthSignInStep.resetPassword: + final resetResult = await _cognito.resetPassword( + username: email, + ); + return Result.error("Reset password result: $resetResult"); + case AuthSignInStep.confirmSignUp: + // Resend the sign up code to the registered device. + final resendResult = await _cognito.resendSignUpCode( + username: email, + ); + return Result.error( + _handleCodeDelivery(resendResult.codeDeliveryDetails), + ); + case AuthSignInStep.done: + return Result.ok(null); + default: + Arcane.log( + "Sign-in failed", + level: Level.warning, + metadata: { + "result": result.toString(), + }, + ); + return Result.error( + "Unexpected sign-in result: ${result.nextStep.signInStep}", + ); + } + } + + String _handleCodeDelivery(AuthCodeDeliveryDetails codeDeliveryDetails) { + // TODO(any): localize this + return "A confirmation code has been sent to ${codeDeliveryDetails.destination}. " + "Please check your ${codeDeliveryDetails.deliveryMedium.name} for the code."; + } + + @override + Future> resendVerificationCode(String email) async { + try { + final result = await _cognito.resendSignUpCode( + username: email.toLowerCase(), + ); + final codeDeliveryDetails = result.codeDeliveryDetails; + final String returnValue = _handleCodeDelivery(codeDeliveryDetails); + return Result.ok(returnValue); + } on AuthException catch (e) { + return Result.error("Error resending verification code: ${e.message}"); + } + } + + @override + Future> signup({ + required String password, + required String email, + }) async { + try { + final String accountEmail = email.toLowerCase(); + final userAttributes = { + AuthUserAttributeKey.email: accountEmail, + }; + final SignUpResult result = await _cognito.signUp( + username: accountEmail, + password: password, + options: SignUpOptions( + userAttributes: userAttributes, + ), + ); + + if (result.nextStep.signUpStep == AuthSignUpStep.confirmSignUp) { + return Result.ok(SignUpStep.confirmSignUp); + } + + return Result.ok(SignUpStep.done); + } on AuthException catch (e) { + return Result.error("Error signing up user: ${e.message}"); + } + } + + @override + Future> confirmSignup({ + required String username, + required String confirmationCode, + }) async { + try { + final CognitoSignUpResult result = await _cognito.confirmSignUp( + username: username.toLowerCase(), + confirmationCode: confirmationCode, + ); + + return Result.ok(result.isSignUpComplete); + } on AuthException catch (e) { + return Result.error("Error confirming user: ${e.message}"); + } + } + + @override + Future> resetPassword({ + required String email, + String? newPassword, + String? code, + }) async { + try { + late ResetPasswordResult result; + if (newPassword != null && code != null) { + result = await _cognito.confirmResetPassword( + username: email, + newPassword: newPassword, + confirmationCode: code, + ); + } + + if (newPassword == null && code == null) { + result = await _cognito.resetPassword( + username: email, + ); + } + + return Result.ok(result.isPasswordReset); + } on AuthException catch (e) { + return Result.error("Error resetting the password: ${e.message}"); + } + } + + @override + Future init() async { + if (_mocked) return; + + if (Amplify.isConfigured) return; + + final plugin = AmplifyAuthCognito(); + + await Amplify.addPlugin(plugin); + await Amplify.configure(_amplifyconfig); + } + + @visibleForTesting + static void setMocked() { + _mocked = true; + } + + static final String _amplifyconfig = ''' +{ + "UserAgent": "aws-amplify-cli/2.0", + "Version": "1.0", + "auth": { + "plugins": { + "awsCognitoAuthPlugin": { + "IdentityManager": { + "Default": {} + }, + "CredentialsProvider": { + "CognitoIdentity": { + "Default": { + "PoolId": "${EnvVar.cognitoPoolId.value}", + "Region": "${EnvVar.cognitoRegion.value}" + } + } + }, + "CognitoUserPool": { + "Default": { + "PoolId": "${EnvVar.cognitoPoolId.value}", + "AppClientId": "${EnvVar.cognitoClientId.value}", + "Region": "${EnvVar.cognitoRegion.value}" + } + }, + "Auth": { + "Default": { + "authenticationFlowType": "USER_SRP_AUTH", + "OAuth": { + "WebDomain": "${EnvVar.authUrl.value}", + "AppClientId": "${EnvVar.cognitoClientId.value}", + "SignInRedirectURI": "${EnvVar.redirectUri.value}", + "SignOutRedirectURI": "${EnvVar.redirectUri.value}", + "Scopes": [ + "phone", + "email", + "openid", + "profile", + "aws.cognito.signin.user.admin" + ] + } + } + } + } + } + } +}'''; +} diff --git a/interfaces/logging/debug_console_interface.dart b/interfaces/logging/debug_console_interface.dart new file mode 100644 index 0000000..ae0895d --- /dev/null +++ b/interfaces/logging/debug_console_interface.dart @@ -0,0 +1,98 @@ +import "dart:convert"; +import "dart:io" show Platform; + +import "package:arcane_framework/arcane_framework.dart"; +import "package:arcane_helper_utils/arcane_helper_utils.dart"; +import "package:flutter/foundation.dart"; +import "package:logger/logger.dart" as l; + +class DebugConsole implements LoggingInterface { + static final DebugConsole _instance = DebugConsole._internal(); + static DebugConsole get I => _instance; + + final bool _initialized = true; + + @override + bool get initialized => I._initialized; + + DebugConsole._internal(); + + @visibleForTesting + void setMocked() => _mocked = true; + bool _mocked = false; + + @override + void log( + String message, { + Map? metadata, + Level? level, + StackTrace? stackTrace, + }) { + if (Feature.logging.disabled) return; + if (Feature.debugConsoleLogging.disabled) return; + if (!kDebugMode) return; + if (!initialized) init(); + + const Level cutoff = AppConfig.debugLoggingThreshold; + + if ((level?.value ?? Level.debug.value) < cutoff.value) return; + + final l.Level logLevel = l.Level.values + .firstWhere((value) => value.name == (level ?? Level.debug).name); + + final Map localMetadata = metadata ?? {}; + + final String? module = localMetadata["module"] as String?; + final String? method = localMetadata["method"] as String?; + + const JsonEncoder encoder = JsonEncoder.withIndent(" "); + final String? prettyprint = + (localMetadata.isNotEmpty) ? encoder.convert(localMetadata) : null; + + final l.Logger logger = l.Logger( + level: logLevel, + printer: l.PrettyPrinter( + methodCount: 2, + errorMethodCount: kDebugMode && + !(level == Level.error || + level == Level.warning || + level == Level.trace || + level == Level.fatal) + ? 4 + : 8, + stackTraceBeginIndex: 1, + lineLength: 120, + colors: !Platform.isIOS, + printEmojis: kDebugMode, + dateTimeFormat: l.DateTimeFormat.none, + ), + ); + + // Print the message to the debug console + String prefix = ""; + if (module != null) prefix += "[$module]"; + if (method != null) prefix += "[$method]"; + if (prefix.isNotEmpty) prefix += " "; + message = "$prefix$message"; + + if (prettyprint.isNotNullOrEmpty) message += "\n\n$prettyprint"; + + localMetadata.removeWhere((key, value) => key == "module"); + localMetadata.removeWhere((key, value) => key == "method"); + localMetadata.removeWhere((key, value) => key == "filenameAndLineNumber"); + + logger.log( + logLevel, + message, + error: localMetadata["error"] ?? "", + stackTrace: stackTrace, + ); + } + + @override + Future init() async { + if (_mocked) return null; + + return I; + } +} diff --git a/interfaces/logging/debug_print_interface.dart b/interfaces/logging/debug_print_interface.dart new file mode 100644 index 0000000..eec08e0 --- /dev/null +++ b/interfaces/logging/debug_print_interface.dart @@ -0,0 +1,24 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter/foundation.dart"; + +class DebugPrint implements LoggingInterface { + DebugPrint._internal(); + static final DebugPrint _instance = DebugPrint._internal(); + static DebugPrint get I => _instance; + + @override + bool get initialized => true; + + @override + void log( + String message, { + Map? metadata, + Level? level = Level.debug, + StackTrace? stackTrace, + }) { + debugPrint("[${level!.name}] $message ($metadata)"); + } + + @override + Future init() async => I; +} diff --git a/interfaces/logging/new_relic_interface.dart b/interfaces/logging/new_relic_interface.dart new file mode 100644 index 0000000..d14eb62 --- /dev/null +++ b/interfaces/logging/new_relic_interface.dart @@ -0,0 +1,115 @@ +import "dart:io" show Platform; + +import "package:arcane_framework/arcane_framework.dart"; +import "package:arcane_helper_utils/arcane_helper_utils.dart"; +import "package:flutter/foundation.dart"; +import "package:newrelic_mobile/config.dart"; +import "package:newrelic_mobile/newrelic_mobile.dart"; + +class NewRelic implements LoggingInterface { + static final NewRelic _instance = NewRelic._internal(); + static NewRelic get I => _instance; + + bool _initialized = false; + @override + bool get initialized => I._initialized; + + NewRelic._internal(); + + @visibleForTesting + void setMocked() => _mocked = true; + bool _mocked = false; + + @override + void log( + String message, { + Map? metadata, + Level? level, + StackTrace? stackTrace, + }) { + if (Feature.logging.disabled) return; + if (Feature.newRelic.disabled) return; + if (!initialized) return; + + final Map metadataToSend = metadata ?? {}; + + // New Relic strips this out, anyway, so let's not cause additional logs. + metadataToSend.removeWhere((key, _) => key == "timestamp"); + + // Add the logging level to the metadata + metadataToSend.putIfAbsent( + "level", + () => (level?.name ?? "debug").capitalize, + ); + + NewrelicMobile.instance.recordCustomEvent( + "App", + eventName: message, + eventAttributes: metadataToSend, + ); + + if (stackTrace != null) { + NewrelicMobile.instance.recordError(message, stackTrace); + } + } + + @override + Future init() async { + if (_mocked) return null; + if (Feature.newRelic.disabled) return null; + if (initialized) return I; + + final String appToken = AppEnv.valueOf( + Platform.isAndroid + ? EnvVar.newRelicAppTokenAndroid + : EnvVar.newRelicAppTokenIos, + ); + + final Config config = Config( + accessToken: appToken, + + //Android Specific + // Optional: Enable or disable collection of event data. + analyticsEventEnabled: true, + // Optional: Enable or disable reporting successful HTTP requests to the MobileRequest event type. + networkErrorRequestEnabled: true, + // Optional: Enable or disable reporting network and HTTP request errors to the MobileRequestError event type. + networkRequestEnabled: true, + // Optional: Enable or disable crash reporting. + crashReportingEnabled: true, + // Optional: Enable or disable interaction tracing. Trace instrumentation still occurs, but no traces are harvested. This will disable default and custom interactions. + interactionTracingEnabled: true, + // Optional: Enable or disable capture of HTTP response bodies for HTTP error traces and MobileRequestError events. + httpResponseBodyCaptureEnabled: true, + // Optional: Enable or disable agent logging. + loggingEnabled: true, + // iOS specific + // Optional: Enable or disable automatic instrumentation of WebViews + webViewInstrumentation: false, + //Optional: Enable or disable Print Statements as Analytics Events + printStatementAsEventsEnabled: false, + // Optional: Enable or disable automatic instrumentation of HTTP Request + httpInstrumentationEnabled: true, + // Optional: Enable or disable reporting data using different endpoints for US government clients + fedRampEnabled: false, + // Optional: Enable or disable offline data storage when no internet connection is available. + offlineStorageEnabled: true, + // iOS Specific + // Optional: Enable or disable background reporting functionality. + backgroundReportingEnabled: false, + // iOS Specific + // Optional: Enable or disable to use our new, more stable, event system for iOS agent. + newEventSystemEnabled: true, + + // Optional: Enable or disable distributed tracing. + distributedTracingEnabled: true, + ); + + await NewrelicMobile.instance.startAgent(config); + + // Initialization complete. + I._initialized = true; + + return I; + } +} diff --git a/misc/app_env.dart b/misc/app_env.dart new file mode 100644 index 0000000..7a80abc --- /dev/null +++ b/misc/app_env.dart @@ -0,0 +1,55 @@ +import "package:flutter/foundation.dart"; +import "package:flutter_dotenv/flutter_dotenv.dart"; + +class AppEnv { + /// Fetches the environment variable value for the given [EnvVar]. If the + /// value is not set, defaults to an empty string. + static String valueOf(EnvVar val) { + return dotenv.maybeGet(val.key) ?? ""; + } + + /// Returns [true] if all of the [EnvVar] variables are set. + static bool get hasEnv => + dotenv.isEveryDefined(EnvVar.values.map((e) => e.key).toList()); + + static Future init() async { + await dotenv.load(); + } + + static const FlutterMode flutterMode = kDebugMode + ? FlutterMode.debug + : kReleaseMode + ? FlutterMode.release + : kProfileMode + ? FlutterMode.profile + : FlutterMode.unknown; +} + +enum FlutterMode { + debug, + profile, + release, + unknown, +} + +extension EnvVarValue on EnvVar { + /// The value of the [EnvVar] as a string. If the environment variable is not + /// set, returns an empty string. + String get value => AppEnv.valueOf(this); +} + +enum EnvVar { + /// The environment to use for the API calls. Returns either [dev] or [prod]. + /// + /// Example `.env` configuration: + /// ``` + /// API_ENVIRONMENT="dev" + /// ``` + apiEnvironment("API_ENVIRONMENT"), + ; + + /// The environment variable to use when retrieving the value of this [EnvVar]. + final String key; + + const EnvVar(this.key); +} diff --git a/misc/app_scaffold.dart b/misc/app_scaffold.dart new file mode 100644 index 0000000..1798c0c --- /dev/null +++ b/misc/app_scaffold.dart @@ -0,0 +1,87 @@ +import "package:flutter/material.dart"; +import "package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart"; +import "package:flutter_gen/gen_l10n/app_localizations.dart"; +import "package:flutter_svg/flutter_svg.dart"; +import "package:go_router/go_router.dart"; + +class AppScaffold extends StatelessWidget { + final Widget body; + final Widget? secondaryBody; + + const AppScaffold({ + required this.body, + this.secondaryBody, + super.key = const ValueKey("AppScaffold"), + }); + + @override + Widget build(BuildContext context) { + return AdaptiveLayout( + internalAnimations: false, + body: SlotLayout( + config: { + Breakpoints.small: SlotLayout.from( + key: const Key("Body Small"), + builder: (context) => secondaryBody ?? body, + ), + Breakpoints.mediumAndUp: SlotLayout.from( + key: const Key("Body Medium and Up"), + builder: (context) => body, + ), + }, + ), + secondaryBody: SlotLayout( + config: { + Breakpoints.small: SlotLayout.from( + key: const Key("Body Small"), + builder: null, + ), + Breakpoints.mediumAndUp: SlotLayout.from( + key: const Key("Body Medium"), + builder: secondaryBody, + ), + }, + ), + ); + } +} + +class AppScaffoldShell extends StatelessWidget { + final StatefulNavigationShell navigationShell; + const AppScaffoldShell({ + required this.navigationShell, + super.key = const ValueKey("AppScaffoldShell"), + }); + + @override + Widget build(BuildContext context) { + return AdaptiveScaffold( + useDrawer: false, + internalAnimations: false, + selectedIndex: navigationShell.currentIndex, + onSelectedIndexChange: onNavigationEvent, + destinations: [ + NavigationDestination( + label: AppLocalizations.of(context).tabHome, + icon: const Icon(Icons.home), + ), + NavigationDestination( + label: AppLocalizations.of(context).tabHistory, + icon: const Icon(Icons.change_history_rounded), + ), + NavigationDestination( + label: AppLocalizations.of(context).tabProfile, + icon: const Icon(Icons.account_circle), + ), + ], + body: (_) => navigationShell, + ); + } + + void onNavigationEvent(int index) { + navigationShell.goBranch( + index, + initialLocation: index == navigationShell.currentIndex, + ); + } +} diff --git a/misc/feature.dart b/misc/feature.dart new file mode 100644 index 0000000..02368ef --- /dev/null +++ b/misc/feature.dart @@ -0,0 +1,13 @@ +enum Feature { + /// Prints the current auth token to the debug log each time the API makes a + /// request. + debugPrintAuthToken(kDebugMode), + ; + + /// Determines whether the given [Feature] is enabled by default when the + /// application launches. Features can be enabled or disabled during runtime, + /// regardless of this value. + final bool enabledAtStartup; + + const Feature(this.enabledAtStartup); +} diff --git a/misc/injector.dart b/misc/injector.dart new file mode 100644 index 0000000..7ecfac5 --- /dev/null +++ b/misc/injector.dart @@ -0,0 +1,83 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:dio/dio.dart"; +import "package:flutter_secure_storage/flutter_secure_storage.dart"; +import "package:get_it/get_it.dart"; + +abstract class AppInjector { + static final GetIt getIt = GetIt.I; + + static void _registerApis() { + getIt.registerSingleton( + SetupApi(getIt()), + ); + } + + static void _registerHelpers() { + getIt.registerSingleton( + const FlutterSecureStorage( + aOptions: AndroidOptions( + encryptedSharedPreferences: true, + ), + ), + ); + getIt.registerSingleton( + SecureStorageRepository(getIt()), + ); + getIt.registerLazySingleton( + () => AuthorizationInterceptor(), + ); + getIt.registerLazySingleton( + () => DioHelper.createDioInstance(getIt), + ); + } + + static Future init() async { + Arcane.log( + "Initializing injector...", + level: Level.info, + ); + + await getIt.reset(); + _registerHelpers(); + _registerApis(); + await getIt.allReady(); + + await IdService.I.init(); + + Arcane.log( + "Injector initialized.", + level: Level.info, + ); + } + + static void resetDio() { + if (getIt.isRegistered()) { + getIt.unregister(); + } + + getIt.registerLazySingleton( + () => DioHelper.createDioInstance(getIt), + ); + } + + static Future configureAsDebug() async { + Arcane.log( + "Unregistering production APIs and replacing with Debug versions", + level: Level.fatal, + ); + + getIt.unregister(); + + await getIt.allReady(); + + getIt.registerSingleton( + DebugSecureStorageRepository(), + ); + + final SecureStorageRepository storage = getIt(); + + getIt.registerSingleton(DebugMyApi(storage)); + + await getIt.allReady(); + } +} diff --git a/misc/state_provider.dart b/misc/state_provider.dart new file mode 100644 index 0000000..9a11d67 --- /dev/null +++ b/misc/state_provider.dart @@ -0,0 +1,30 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter/material.dart"; +import "package:flutter_bloc/flutter_bloc.dart"; +import "package:get_it/get_it.dart"; + +class AppStateProvider extends StatelessWidget { + final Widget child; + + const AppStateProvider({ + required this.child, + super.key, + }); + + @override + Widget build(BuildContext context) { + final ValueKey key = ValueKey( + "${context.watch().state.name}-${IdService.I.sessionId.value}", + ); + + return MultiBlocProvider( + providers: [ + BlocProvider( + key: key, + create: (context) => MyBloc(GetIt.I()), + ), + ], + child: child, + ); + } +} diff --git a/services/id_service.dart b/services/id_service.dart new file mode 100644 index 0000000..6057cba --- /dev/null +++ b/services/id_service.dart @@ -0,0 +1,127 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter/foundation.dart"; +import "package:get_it/get_it.dart"; +import "package:uuid/uuid.dart"; + +/// A singleton service that manages unique IDs, including install and session IDs. +/// +/// The `IdService` provides a way to generate and retrieve unique identifiers +/// for application installs and sessions. It interacts with secure storage to persist +/// the install ID across app launches and generates new session IDs for each session. +class IdService extends ArcaneService { + /// Whether the service is mocked for testing purposes. + static bool _mocked = false; + + /// The singleton instance of `ArcaneIdService`. + static final IdService _instance = IdService._internal(); + + /// Provides access to the singleton instance of `IdService`. + static IdService get I => _instance; + + IdService._internal(); + + SecureStorageRepository get _storage => GetIt.I(); + + /// Whether the service has been initialized. + bool _initialized = false; + + /// Returns `true` if the service has been initialized. + bool get initialized => I._initialized; + + /// The unique install ID. + /// + /// This ID is persisted across app launches and is used to uniquely identify + /// the installation of the app. + String? _installId; + + /// Retrieves the install ID. + /// + /// If the install ID is not yet initialized, this method initializes the service + /// and generates a new ID if necessary. + /// + /// Example: + /// ```dart + /// String? id = IdService.I.installId; + /// ``` + String? get installId => I._installId; + + /// The unique session ID. + /// + /// This ID is generated for each app session and is used to uniquely identify + /// the current session. + String? _sessionId; + + /// Retrieves the session ID. + /// + /// If the session ID is not yet initialized, this method initializes the service + /// and generates a new session ID. + /// + /// Example: + /// ```dart + /// String? sessionId = ArcaneIdService.I.sessionId.value; + /// ``` + ValueListenable get sessionId => + ValueNotifier(I._sessionId); + + /// Generates a new unique ID. + /// + /// This method uses UUID version 7 to generate a new unique ID. + String get newId => uuid.v7(); + + /// The `Uuid` instance used for generating unique IDs. + static const Uuid uuid = Uuid(); + + /// Initializes the `IdService`. + /// + /// This method retrieves the install ID from secure storage, generating and storing a new + /// one if it does not exist. It also generates a new session ID. + /// + /// Example: + /// ```dart + /// await IdService.I._init(); + /// ``` + Future init() async { + if (_mocked) return; + Arcane.log( + "Initializing ID Service", + level: Level.debug, + ); + + I._installId = await _storage.getValue( + SecureStorageRepository.installIdKey, + ); + + if (I._installId == null) { + // Generate a new ID and store it + I._installId = uuid.v7(); + await _storage.setValue( + SecureStorageRepository.installIdKey, + I._installId, + ); + } + + I._sessionId = uuid.v7(); + I._initialized = true; + notifyListeners(); + } + + /// Sets the service as mocked for testing purposes. + /// + /// When the service is mocked, it bypasses certain initializations and uses + /// mocked data for testing. + @visibleForTesting + static void setMocked() => _mocked = true; +} + +/// Enum representing different types of IDs managed by the `ArcaneIdService`. +/// +/// The `ID` enum has two possible values: +/// - `session`: Represents the session ID. +/// - `install`: Represents the install ID. +enum ID { + /// Represents the session ID. + session, + + /// Represents the install ID. + install, +} diff --git a/services/ios_app_tracking_service.dart b/services/ios_app_tracking_service.dart new file mode 100644 index 0000000..c4ab772 --- /dev/null +++ b/services/ios_app_tracking_service.dart @@ -0,0 +1,90 @@ +import "dart:io"; + +import "package:app_tracking_transparency/app_tracking_transparency.dart"; +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter/material.dart"; +import "package:flutter_gen/gen_l10n/app_localizations.dart"; + +class TrackingService { + TrackingService._internal(); + + static final TrackingService _instance = TrackingService._internal(); + + static TrackingService get I => _instance; + + TrackingStatus _trackingStatus = TrackingStatus.notDetermined; + + TrackingStatus get trackingStatus => I._trackingStatus; + + bool _initialized = false; + + bool get initialized => I._initialized; + + @visibleForTesting + void setMocked() => _mocked = true; + bool _mocked = false; + + Future init() async { + if (_mocked) return; + + _trackingStatus = await AppTrackingTransparency.trackingAuthorizationStatus; + + if (!(Platform.isIOS || Platform.isMacOS)) { + _trackingStatus = TrackingStatus.authorized; + } + + _initialized = true; + } + + Future initalizeAppTracking(BuildContext context) async { + if (_mocked) return null; + if (!initialized) await init(); + + // If the system can show an authorization request dialog + if (trackingStatus == TrackingStatus.notDetermined) { + // Show a custom explainer dialog before the system dialog + if (!context.mounted) return null; + await _showTrackingDialog(context); + // Wait for dialog popping animation + await Future.delayed(const Duration(milliseconds: 200)); + // Request system's tracking authorization dialog + await AppTrackingTransparency.requestTrackingAuthorization(); + } + + _trackingStatus = await AppTrackingTransparency.trackingAuthorizationStatus; + + if (trackingStatus == TrackingStatus.authorized) { + await Arcane.logger.initializeInterfaces(); + } + + return trackingStatus; + } + + Future _showTrackingDialog(BuildContext context) async { + await showAdaptiveDialog( + context: context, + builder: (context) { + final buttonStyle = Theme.of(context).textButtonTheme.style?.copyWith( + textStyle: const WidgetStatePropertyAll( + TextStyle( + decoration: TextDecoration.none, + ), + ), + ); + return AlertDialog.adaptive( + title: Text(AppLocalizations.of(context).appTrackingTitle), + content: Text(AppLocalizations.of(context).appTrackingBody), + actions: [ + TextButton( + style: buttonStyle, + onPressed: () async { + Navigator.of(context).pop(); + }, + child: Text(AppLocalizations.of(context).continueText), + ), + ], + ); + }, + ); + } +} diff --git a/services/secure_storage_repository.dart b/services/secure_storage_repository.dart new file mode 100644 index 0000000..abb8c15 --- /dev/null +++ b/services/secure_storage_repository.dart @@ -0,0 +1,138 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:arcane_helper_utils/arcane_helper_utils.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter_secure_storage/flutter_secure_storage.dart"; + +class SecureStorageRepository { + final FlutterSecureStorage _storage; + + SecureStorageRepository(this._storage); + + static const String installIdKey = "install_id"; + + Future deleteAll() async { + try { + final String? cachedInstallId = await getValue(installIdKey); + + await _storage.deleteAll(); + + if (cachedInstallId.isNotNullOrEmpty) { + await setValue(installIdKey, cachedInstallId); + } + + return true; + } catch (exception) { + return false; + } + } + + Future getValue(String key) async { + if (Feature.secureStorageRepositoryLogs.enabled) { + Arcane.log( + "Value requested from secure storage", + level: Level.debug, + metadata: { + "key": key, + }, + ); + } + + String? value; + + try { + value = await _storage.read(key: key); + if (value.isNullOrEmpty && Feature.secureStorageRepositoryLogs.enabled) { + Arcane.log( + "Value retrieved from secure storage is empty", + level: Level.info, + metadata: { + "key": key, + }, + ); + } + + if (Feature.secureStorageRepositoryLogs.enabled) { + Arcane.log( + "Successfully retrived value from secure storage", + level: Level.debug, + metadata: { + "key": key, + if (kDebugMode) "value": "$value", + }, + ); + } + } catch (e) { + Arcane.log( + "Unable to retrieve value from secure storage", + level: Level.error, + metadata: { + "key": key, + }, + ); + } + + return value; + } + + Future setValue(String key, String? value) async { + if (Feature.secureStorageRepositoryLogs.enabled) { + Arcane.log( + "Setting value in secure storage", + level: Level.debug, + metadata: { + "key": key, + "value": "$value", + }, + ); + } + + try { + await _storage.write(key: key, value: value); + + if (Feature.secureStorageRepositoryLogs.enabled) { + Arcane.log( + "Successfully set value in secure storage", + level: Level.debug, + metadata: { + "key": key, + if (kDebugMode) "value": "$value", + }, + ); + } + return true; + } catch (e) { + Arcane.log( + "Unable to set value in secure storage", + level: Level.error, + metadata: { + "key": key, + if (kDebugMode) "value": "$value", + }, + ); + return false; + } + } +} + +class DebugSecureStorageRepository implements SecureStorageRepository { + @override + FlutterSecureStorage get _storage => throw UnimplementedError(); + + @override + Future deleteAll() async { + return true; + } + + @override + Future getValue(String key) async { + return switch (key) { + "install_id" => "install id", + _ => throw Exception("Unhandled case in secure storage"), + }; + } + + @override + Future setValue(String key, String? value) async { + return true; + } +}