From 6ee146b95acb8ab051fcdb848f5d1577e5f3822c Mon Sep 17 00:00:00 2001 From: Arseni Date: Fri, 12 Dec 2025 16:39:18 +0300 Subject: [PATCH 1/2] PostHog last fixes hopefully --- frontend/pshared/lib/config/common.dart | 4 + frontend/pshared/lib/config/mobile.dart | 2 + frontend/pshared/lib/config/web.dart | 6 + frontend/pshared/lib/models/resources.dart | 7 +- frontend/pshared/lib/provider/account.dart | 23 ++- .../pshared/lib/provider/permissions.dart | 12 ++ frontend/pweb/lib/main.dart | 21 ++- .../lib/pages/address_book/form/page.dart | 13 +- frontend/pweb/lib/pages/loaders/account.dart | 62 ++++++-- .../pweb/lib/pages/loaders/permissions.dart | 42 +++--- frontend/pweb/lib/pages/login/form.dart | 4 + .../pweb/lib/pages/payment_methods/page.dart | 5 +- .../settings/profile/account/locale.dart | 6 +- frontend/pweb/lib/providers/account.dart | 17 +++ frontend/pweb/lib/services/posthog.dart | 136 ++++++++++++++++++ .../pweb/lib/widgets/sidebar/side_menu.dart | 8 +- .../Flutter/GeneratedPluginRegistrant.swift | 2 + frontend/pweb/pubspec.yaml | 1 + 18 files changed, 317 insertions(+), 54 deletions(-) create mode 100644 frontend/pweb/lib/providers/account.dart create mode 100644 frontend/pweb/lib/services/posthog.dart diff --git a/frontend/pshared/lib/config/common.dart b/frontend/pshared/lib/config/common.dart index c2240f9..51641db 100644 --- a/frontend/pshared/lib/config/common.dart +++ b/frontend/pshared/lib/config/common.dart @@ -11,6 +11,8 @@ class CommonConstants { static String apiEndpoint = '/api/v1'; static String amplitudeSecret = 'c3d75b3e2520d708440acbb16b923e79'; static String amplitudeServerZone = 'EU'; + static String posthogApiKey = 'phc_lVhbruaZpxiQxppHBJpL36ARnPlkqbCewv6cauoceTN'; + static String posthogHost = 'https://eu.i.posthog.com'; static Locale defaultLocale = const Locale('en'); static String defaultCurrency = 'EUR'; static int defaultDimensionLength = 500; @@ -36,6 +38,8 @@ class CommonConstants { apiEndpoint = configJson['apiEndpoint'] ?? apiEndpoint; amplitudeSecret = configJson['amplitudeSecret'] ?? amplitudeSecret; amplitudeServerZone = configJson['amplitudeServerZone'] ?? amplitudeServerZone; + posthogApiKey = configJson['posthogApiKey'] ?? posthogApiKey; + posthogHost = configJson['posthogHost'] ?? posthogHost; defaultLocale = Locale(configJson['defaultLocale'] ?? defaultLocale.languageCode); defaultCurrency = configJson['defaultCurrency'] ?? defaultCurrency; wsProto = configJson['wsProto'] ?? wsProto; diff --git a/frontend/pshared/lib/config/mobile.dart b/frontend/pshared/lib/config/mobile.dart index ec5f56d..d340010 100644 --- a/frontend/pshared/lib/config/mobile.dart +++ b/frontend/pshared/lib/config/mobile.dart @@ -15,6 +15,8 @@ class Constants extends CommonConstants { static String get currentOrgKey => CommonConstants.currentOrgKey; static String get apiUrl => CommonConstants.apiUrl; static String get serviceUrl => CommonConstants.serviceUrl; + static String get posthogApiKey => CommonConstants.posthogApiKey; + static String get posthogHost => CommonConstants.posthogHost; static int get defaultDimensionLength => CommonConstants.defaultDimensionLength; static String get deviceIdStorageKey => CommonConstants.deviceIdStorageKey; static String get nilObjectRef => CommonConstants.nilObjectRef; diff --git a/frontend/pshared/lib/config/web.dart b/frontend/pshared/lib/config/web.dart index cc49d85..8339cf5 100644 --- a/frontend/pshared/lib/config/web.dart +++ b/frontend/pshared/lib/config/web.dart @@ -21,6 +21,8 @@ extension AppConfigExtension on AppConfig { external String? get apiEndpoint; external String? get amplitudeSecret; external String? get amplitudeServerZone; + external String? get posthogApiKey; + external String? get posthogHost; external String? get defaultLocale; external String? get wsProto; external String? get wsEndpoint; @@ -40,6 +42,8 @@ class Constants extends CommonConstants { static String get currentOrgKey => CommonConstants.currentOrgKey; static String get apiUrl => CommonConstants.apiUrl; static String get serviceUrl => CommonConstants.serviceUrl; + static String get posthogApiKey => CommonConstants.posthogApiKey; + static String get posthogHost => CommonConstants.posthogHost; static int get defaultDimensionLength => CommonConstants.defaultDimensionLength; static String get deviceIdStorageKey => CommonConstants.deviceIdStorageKey; static String get nilObjectRef => CommonConstants.nilObjectRef; @@ -57,6 +61,8 @@ class Constants extends CommonConstants { 'apiEndpoint': config.apiEndpoint, 'amplitudeSecret': config.amplitudeSecret, 'amplitudeServerZone': config.amplitudeServerZone, + 'posthogApiKey': config.posthogApiKey, + 'posthogHost': config.posthogHost, 'defaultLocale': config.defaultLocale, 'wsProto': config.wsProto, 'wsEndpoint': config.wsEndpoint, diff --git a/frontend/pshared/lib/models/resources.dart b/frontend/pshared/lib/models/resources.dart index aaa1df7..662f9ab 100644 --- a/frontend/pshared/lib/models/resources.dart +++ b/frontend/pshared/lib/models/resources.dart @@ -79,12 +79,13 @@ enum ResourceType { @JsonValue('payments') payments, - @JsonValue('payment_methods') - paymentMethods, - + /// Represents payment orchestration service @JsonValue('payment_orchestrator') paymentOrchestrator, + @JsonValue('payment_methods') + paymentMethods, + /// Represents permissions service @JsonValue('permissions') permissions, diff --git a/frontend/pshared/lib/provider/account.dart b/frontend/pshared/lib/provider/account.dart index 9413913..f246241 100644 --- a/frontend/pshared/lib/provider/account.dart +++ b/frontend/pshared/lib/provider/account.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:share_plus/share_plus.dart'; @@ -20,6 +22,8 @@ import 'package:pshared/utils/exception.dart'; class AccountProvider extends ChangeNotifier { + AccountProvider(); + static String get currentUserRef => Constants.nilObjectRef; // The resource now wraps our Account? state along with its loading/error state. @@ -27,6 +31,7 @@ class AccountProvider extends ChangeNotifier { Resource get resource => _resource; late LocaleProvider _localeProvider; PendingLogin? _pendingLogin; + Future? _restoreFuture; Account? get account => _resource.data; PendingLogin? get pendingLogin => _pendingLogin; @@ -52,9 +57,18 @@ class AccountProvider extends ChangeNotifier { ); } - // Private helper to update the resource and notify listeners. + @protected + Future onAccountChanged(Account? previous, Account? current) => Future.value(); + void _setResource(Resource newResource) { + final previousAccount = _resource.data; _resource = newResource; + final currentAccount = newResource.data; + + if (previousAccount != currentAccount) { + unawaited(onAccountChanged(previousAccount, currentAccount)); + } + notifyListeners(); } @@ -220,4 +234,11 @@ class AccountProvider extends ChangeNotifier { rethrow; } } + + Future restoreIfPossible() { + return _restoreFuture ??= AuthorizationService.isAuthorizationStored().then((hasAuth) async { + if (!hasAuth) return; + await restore(); + }); + } } diff --git a/frontend/pshared/lib/provider/permissions.dart b/frontend/pshared/lib/provider/permissions.dart index 7b6e0d5..1e1209c 100644 --- a/frontend/pshared/lib/provider/permissions.dart +++ b/frontend/pshared/lib/provider/permissions.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; @@ -21,9 +23,17 @@ class PermissionsProvider extends ChangeNotifier { Resource _userAccess = Resource(data: null, isLoading: false, error: null); late OrganizationsProvider _organizations; bool _isLoaded = false; + String? _loadedOrgRef; + //For permissions to auto-load when an organization is set, so the dashboard no longer hangs waiting for permissions to become ready. void update(OrganizationsProvider venue) { _organizations = venue; + // Trigger a reload when organization changes or when permissions were never loaded. + if (_organizations.isOrganizationSet && + _loadedOrgRef != _organizations.current.id && + !_userAccess.isLoading) { + unawaited(load()); + } } // Generic wrapper to perform service calls and reload state @@ -56,6 +66,7 @@ class PermissionsProvider extends ChangeNotifier { _userAccess = _userAccess.copyWith(data: allAccess, isLoading: false); } _isLoaded = true; + _loadedOrgRef = orgRef; } catch (e) { _userAccess = _userAccess.copyWith( error: e is Exception ? e : Exception(e.toString()), @@ -164,6 +175,7 @@ class PermissionsProvider extends ChangeNotifier { void reset() { _userAccess = Resource(data: null, isLoading: false, error: null); _isLoaded = false; + _loadedOrgRef = null; notifyListeners(); } diff --git a/frontend/pweb/lib/main.dart b/frontend/pweb/lib/main.dart index 6ac5990..cd0ae9f 100644 --- a/frontend/pweb/lib/main.dart +++ b/frontend/pweb/lib/main.dart @@ -12,6 +12,8 @@ import 'package:pshared/provider/locale.dart'; import 'package:pshared/provider/permissions.dart'; import 'package:pshared/provider/account.dart'; import 'package:pshared/provider/organizations.dart'; +import 'package:pshared/provider/payment/amount.dart'; +import 'package:pshared/provider/payment/quotation.dart'; import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/pmethods.dart'; @@ -26,8 +28,10 @@ import 'package:pweb/providers/wallets.dart'; import 'package:pweb/providers/wallet_transactions.dart'; import 'package:pweb/services/operations.dart'; import 'package:pweb/services/payments/history.dart'; +import 'package:pweb/services/posthog.dart'; import 'package:pweb/services/wallet_transactions.dart'; import 'package:pweb/services/wallets.dart'; +import 'package:pweb/providers/account.dart'; void _setupLogging() { @@ -39,11 +43,9 @@ void _setupLogging() { } void main() async { - await Constants.initialize(); - WidgetsFlutterBinding.ensureInitialized(); - - // await AmplitudeService.initialize(); + await Constants.initialize(); + await PosthogService.initialize(); _setupLogging(); @@ -56,7 +58,7 @@ void main() async { providers: [ ChangeNotifierProvider(create: (_) => LocaleProvider(null)), ChangeNotifierProxyProvider( - create: (_) => AccountProvider(), + create: (_) => PwebAccountProvider(), update: (context, localeProvider, provider) => provider!..updateProvider(localeProvider), ), ChangeNotifierProxyProvider( @@ -69,6 +71,7 @@ void main() async { update: (context, orgnization, provider) => provider!..update(orgnization), ), ChangeNotifierProvider(create: (_) => CarouselIndexProvider()), + ChangeNotifierProvider( create: (_) => UploadHistoryProvider(service: MockUploadHistoryService())..load(), ), @@ -90,9 +93,17 @@ void main() async { ChangeNotifierProvider( create: (_) => MockPaymentProvider(), ), + ChangeNotifierProvider( create: (_) => OperationProvider(OperationService())..loadOperations(), ), + ChangeNotifierProvider( + create: (_) => PaymentAmountProvider(), + ), + ChangeNotifierProxyProvider2( + create: (_) => QuotationProvider(), + update: (context, orgnization, payment, provider) => provider!..update(orgnization, payment), + ), ], child: const PayApp(), ), diff --git a/frontend/pweb/lib/pages/address_book/form/page.dart b/frontend/pweb/lib/pages/address_book/form/page.dart index 0404025..00186f5 100644 --- a/frontend/pweb/lib/pages/address_book/form/page.dart +++ b/frontend/pweb/lib/pages/address_book/form/page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; @@ -14,6 +16,7 @@ import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/provider.dart'; import 'package:pweb/pages/address_book/form/view.dart'; +import 'package:pweb/services/posthog.dart'; import 'package:pweb/utils/error/snackbar.dart'; import 'package:pweb/utils/payment/label.dart'; import 'package:pweb/utils/snackbar.dart'; @@ -106,11 +109,11 @@ class _AdressBookRecipientFormState extends State { return; } - // AmplitudeService.recipientAddCompleted( - // _type, - // _status, - // _methods.keys.toSet(), - // ); + unawaited(PosthogService.recipientAddCompleted( + _type, + _status, + _methods.keys.toSet(), + )); final recipient = await executeActionWithNotification( context: context, action: _doSave, diff --git a/frontend/pweb/lib/pages/loaders/account.dart b/frontend/pweb/lib/pages/loaders/account.dart index d891e1c..c159b57 100644 --- a/frontend/pweb/lib/pages/loaders/account.dart +++ b/frontend/pweb/lib/pages/loaders/account.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; + import 'package:provider/provider.dart'; import 'package:pshared/provider/account.dart'; @@ -12,24 +14,56 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class AccountLoader extends StatelessWidget { final Widget child; - const AccountLoader({super.key, required this.child}); - @override - Widget build(BuildContext context) => Consumer(builder: (context, provider, _) { - if (provider.isLoading) return const Center(child: CircularProgressIndicator()); - if (provider.error != null) { + static final _logger = Logger('loader.account'); + + void _notifyErrorAndRedirect(BuildContext context, Object error) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; postNotifyUserOfErrorX( - context: context, - errorSituation: AppLocalizations.of(context)!.errorLogin, - exception: provider.error!, + context: context, + errorSituation: AppLocalizations.of(context)!.errorLogin, + exception: error, ); navigateAndReplace(context, Pages.login); - } - if (provider.account == null) { - WidgetsBinding.instance.addPostFrameCallback((_) => navigateAndReplace(context, Pages.login)); + }); + } + + void _restoreAndHandleResult(BuildContext context, AccountProvider provider) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + try { + await provider.restoreIfPossible(); + } catch (error, stack) { + _logger.warning('Account restore failed: $error', error, stack); + } + if (!context.mounted) return; + final latest = Provider.of(context, listen: false); + if (latest.account != null) return; + if (latest.error != null) { + _notifyErrorAndRedirect(context, latest.error!); + return; + } + if (!latest.isLoading) { + navigateAndReplace(context, Pages.login); + } + }); + } + + @override + Widget build(BuildContext context) { + return Consumer(builder: (context, provider, _) { + if (provider.account != null) return child; + if (provider.error != null) { + _notifyErrorAndRedirect(context, provider.error!); + return const Center(child: CircularProgressIndicator()); + } + if (!provider.isLoading) { + _restoreAndHandleResult(context, provider); + } + if (provider.isLoading) return const Center(child: CircularProgressIndicator()); + // In case of error or missing account we show loader while side effects handle navigation. return const Center(child: CircularProgressIndicator()); - } - return child; - }); + }); + } } diff --git a/frontend/pweb/lib/pages/loaders/permissions.dart b/frontend/pweb/lib/pages/loaders/permissions.dart index b77a7de..0db63cf 100644 --- a/frontend/pweb/lib/pages/loaders/permissions.dart +++ b/frontend/pweb/lib/pages/loaders/permissions.dart @@ -13,28 +13,32 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class PermissionsLoader extends StatelessWidget { final Widget child; - const PermissionsLoader({super.key, required this.child}); - @override - Widget build(BuildContext context) => Consumer2(builder: (context, provider, accountProvider, _) { - if (provider.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - if (provider.error != null) { + void _notifyError(BuildContext context, Exception exception) { + WidgetsBinding.instance.addPostFrameCallback((_) { postNotifyUserOfErrorX( - context: context, - errorSituation: AppLocalizations.of(context)!.errorLogin, - exception: provider.error!, + context: context, + errorSituation: AppLocalizations.of(context)!.errorLogin, + exception: exception, ); navigateAndReplace(context, Pages.login); - } - if (provider.error == null && !provider.isReady && accountProvider.account != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - provider.load(); - }); - return const Center(child: CircularProgressIndicator()); - } - return child; - }); + }); + } + + @override + Widget build(BuildContext context) { + return Consumer2( + builder: (context, provider, accountProvider, _) { + if (provider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (provider.error != null) { + _notifyError(context, provider.error!); + return const Center(child: CircularProgressIndicator()); + } + return child; + }, + ); + } } diff --git a/frontend/pweb/lib/pages/login/form.dart b/frontend/pweb/lib/pages/login/form.dart index 1eb3bcc..29b1dce 100644 --- a/frontend/pweb/lib/pages/login/form.dart +++ b/frontend/pweb/lib/pages/login/form.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -14,6 +16,7 @@ import 'package:pweb/widgets/password/password.dart'; import 'package:pweb/widgets/username.dart'; import 'package:pweb/widgets/vspacer.dart'; import 'package:pweb/widgets/error/snackbar.dart'; +import 'package:pweb/services/posthog.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -43,6 +46,7 @@ class _LoginFormState extends State { password: _passwordController.text, locale: context.read().locale.languageCode, ); + unawaited(PosthogService.login(pending: outcome.isPending)); if (outcome.isPending) { // TODO: fix context usage navigateAndReplace(context, Pages.sfactor); diff --git a/frontend/pweb/lib/pages/payment_methods/page.dart b/frontend/pweb/lib/pages/payment_methods/page.dart index 44d3dd9..ccf4b7a 100644 --- a/frontend/pweb/lib/pages/payment_methods/page.dart +++ b/frontend/pweb/lib/pages/payment_methods/page.dart @@ -16,6 +16,7 @@ import 'package:pweb/providers/payment_flow.dart'; import 'package:pweb/pages/payment_methods/payment_page/body.dart'; import 'package:pweb/providers/wallets.dart'; import 'package:pweb/widgets/sidebar/destinations.dart'; +import 'package:pweb/services/posthog.dart'; class PaymentPage extends StatefulWidget { @@ -109,7 +110,7 @@ class _PaymentPageState extends State { void _handleSendPayment() { // TODO: Handle Payment logic - // AmplitudeService.paymentInitiated(); + PosthogService.paymentInitiated(method: _flowProvider.selectedType); } @override @@ -195,4 +196,4 @@ class _PaymentPageState extends State { (method.description?.contains(wallet.walletUserID) ?? false), ); } -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/pages/settings/profile/account/locale.dart b/frontend/pweb/lib/pages/settings/profile/account/locale.dart index 4c11cc5..e1741eb 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/locale.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/locale.dart @@ -1,10 +1,12 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:pshared/provider/locale.dart'; -// import 'package:pweb/services/amplitude.dart'; +import 'package:pweb/services/posthog.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -58,7 +60,7 @@ class LocalePicker extends StatelessWidget { onChanged: (locale) { if (locale != null) { localeProvider.setLocale(locale); - // AmplitudeService.localeChanged(locale); + unawaited(PosthogService.localeChanged(locale)); } }, decoration: const InputDecoration( diff --git a/frontend/pweb/lib/providers/account.dart b/frontend/pweb/lib/providers/account.dart new file mode 100644 index 0000000..9a220c0 --- /dev/null +++ b/frontend/pweb/lib/providers/account.dart @@ -0,0 +1,17 @@ +import 'dart:async'; + +import 'package:pshared/models/account/account.dart'; +import 'package:pshared/provider/account.dart'; + +import 'package:pweb/services/posthog.dart'; + + +class PwebAccountProvider extends AccountProvider { + @override + Future onAccountChanged(Account? previous, Account? current) { + if (current != null) { + return PosthogService.identify(current); + } + return PosthogService.reset(); + } +} diff --git a/frontend/pweb/lib/services/posthog.dart b/frontend/pweb/lib/services/posthog.dart new file mode 100644 index 0000000..15ceaf2 --- /dev/null +++ b/frontend/pweb/lib/services/posthog.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; + +import 'package:logging/logging.dart'; + +import 'package:posthog_flutter/posthog_flutter.dart'; + +import 'package:pshared/config/constants.dart'; +import 'package:pshared/models/account/account.dart'; +import 'package:pshared/models/payment/type.dart'; +import 'package:pshared/models/recipient/status.dart'; +import 'package:pshared/models/recipient/type.dart'; + +import 'package:pweb/widgets/sidebar/destinations.dart'; + + +class PosthogService { + static final _logger = Logger('service.posthog'); + static String? _identifiedUserId; + static bool _initialized = false; + + static bool get isEnabled => _initialized; + + static Future initialize() async { + final apiKey = Constants.posthogApiKey; + if (apiKey.isEmpty) { + _logger.warning('PostHog API key is not configured, analytics disabled.'); + return; + } + + try { + final config = PostHogConfig(apiKey) + ..host = Constants.posthogHost + ..captureApplicationLifecycleEvents = true; + await Posthog().setup(config); + await Posthog().register('client_id', Constants.clientId); + _initialized = true; + _logger.info('PostHog initialized with host ${Constants.posthogHost}'); + } catch (e, st) { + _initialized = false; + _logger.warning('Failed to initialize PostHog: $e', e, st); + } + } + + static Future identify(Account account) async { + if (!_initialized) return; + if (_identifiedUserId == account.id) return; + + await Posthog().identify( + userId: account.id, + userProperties: { + 'email': account.login, + 'name': account.name, + 'locale': account.locale, + 'created_at': account.createdAt.toIso8601String(), + }, + ); + _identifiedUserId = account.id; + } + + static Future reset() async { + if (!_initialized) return; + _identifiedUserId = null; + await Posthog().reset(); + } + + static Future login({required bool pending}) async { + if (!_initialized) return; + await _capture( + 'login', + properties: { + 'result': pending ? 'pending' : 'success', + }, + ); + } + + static Future pageOpened(PayoutDestination page, {String? path, String? uiSource}) async { + if (!_initialized) return; + return _capture( + 'pageOpened', + properties: { + 'page': page.name, + if (path != null) 'path': path, + if (uiSource != null) 'uiSource': uiSource, + }, + ); + } + + static Future localeChanged(Locale locale) async { + if (!_initialized) return; + return _capture( + 'localeChanged', + properties: {'locale': locale.toLanguageTag()}, + ); + } + + static Future recipientAddCompleted( + RecipientType type, + RecipientStatus status, + Set methods, + ) async { + if (!_initialized) return; + return _capture( + 'recipientAddCompleted', + properties: { + 'methods': methods.map((m) => m.name).toList(), + 'type': type.name, + 'status': status.name, + }, + ); + } + + static Future paymentInitiated({PaymentType? method}) async { + if (!_initialized) return; + return _capture( + 'paymentInitiated', + properties: { + if (method != null) 'method': method.name, + }, + ); + } + + static Future _capture( + String eventName, { + Map? properties, + }) async { + if (!_initialized) return; + final filtered = {}; + if (properties != null) { + for (final entry in properties.entries) { + final value = entry.value; + if (value != null) filtered[entry.key] = value; + } + } + await Posthog().capture(eventName: eventName, properties: filtered.isEmpty ? null : filtered); + } +} diff --git a/frontend/pweb/lib/widgets/sidebar/side_menu.dart b/frontend/pweb/lib/widgets/sidebar/side_menu.dart index 5e9cc13..948dd8e 100644 --- a/frontend/pweb/lib/widgets/sidebar/side_menu.dart +++ b/frontend/pweb/lib/widgets/sidebar/side_menu.dart @@ -1,6 +1,8 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; -// import 'package:pweb/services/amplitude.dart'; +import 'package:pweb/services/posthog.dart'; import 'package:pweb/widgets/sidebar/destinations.dart'; @@ -49,7 +51,7 @@ class SideMenuColumn extends StatelessWidget { child: InkWell( onTap: () { onSelected(item); - // AmplitudeService.pageOpened(item, uiSource: 'sidebar'); + unawaited(PosthogService.pageOpened(item, uiSource: 'sidebar')); }, borderRadius: BorderRadius.circular(12), hoverColor: theme.colorScheme.primaryContainer, @@ -76,4 +78,4 @@ class SideMenuColumn extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/frontend/pweb/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/pweb/macos/Flutter/GeneratedPluginRegistrant.swift index 79f5652..6f6fb74 100644 --- a/frontend/pweb/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/frontend/pweb/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import amplitude_flutter import file_selector_macos import flutter_timezone import path_provider_foundation +import posthog_flutter import share_plus import shared_preferences_foundation import sqflite_darwin @@ -19,6 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + PosthogFlutterPlugin.register(with: registry.registrar(forPlugin: "PosthogFlutterPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/frontend/pweb/pubspec.yaml b/frontend/pweb/pubspec.yaml index aeb7e35..610e043 100644 --- a/frontend/pweb/pubspec.yaml +++ b/frontend/pweb/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: sdk: flutter pshared: path: ../pshared + posthog_flutter: ^5.9.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. -- 2.49.1 From a2c05745ad49d0f4df58af96c8eab835e18bee90 Mon Sep 17 00:00:00 2001 From: Arseni Date: Tue, 16 Dec 2025 18:21:49 +0300 Subject: [PATCH 2/2] A --- frontend/pshared/lib/models/auth/state.dart | 7 ++ frontend/pshared/lib/provider/account.dart | 31 +++++-- .../pshared/lib/provider/organizations.dart | 8 ++ .../pshared/lib/provider/permissions.dart | 10 ++- frontend/pweb/lib/pages/loaders/account.dart | 83 ++++++++++--------- .../pweb/lib/pages/loaders/organization.dart | 21 +++-- .../pweb/lib/pages/loaders/permissions.dart | 43 ++++++---- frontend/pweb/lib/providers/two_factor.dart | 23 ++++- frontend/pweb/lib/utils/logout.dart | 24 +++++- frontend/pweb/lib/widgets/appbar/app_bar.dart | 2 +- frontend/pweb/lib/widgets/appbar/profile.dart | 4 +- .../pweb/lib/widgets/drawer/tiles/logout.dart | 12 +-- 12 files changed, 180 insertions(+), 88 deletions(-) create mode 100644 frontend/pshared/lib/models/auth/state.dart diff --git a/frontend/pshared/lib/models/auth/state.dart b/frontend/pshared/lib/models/auth/state.dart new file mode 100644 index 0000000..72e1697 --- /dev/null +++ b/frontend/pshared/lib/models/auth/state.dart @@ -0,0 +1,7 @@ +enum AuthState { + idle, + checking, + ready, + empty, + error, +} \ No newline at end of file diff --git a/frontend/pshared/lib/provider/account.dart b/frontend/pshared/lib/provider/account.dart index f246241..0bfd24e 100644 --- a/frontend/pshared/lib/provider/account.dart +++ b/frontend/pshared/lib/provider/account.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:pshared/models/auth/state.dart'; import 'package:share_plus/share_plus.dart'; @@ -26,12 +27,15 @@ class AccountProvider extends ChangeNotifier { static String get currentUserRef => Constants.nilObjectRef; + /// Auth lifecycle state to avoid multiple ad-hoc flags. + AuthState _authState = AuthState.idle; + AuthState get authState => _authState; + // The resource now wraps our Account? state along with its loading/error state. Resource _resource = Resource(data: null); Resource get resource => _resource; late LocaleProvider _localeProvider; PendingLogin? _pendingLogin; - Future? _restoreFuture; Account? get account => _resource.data; PendingLogin? get pendingLogin => _pendingLogin; @@ -89,6 +93,7 @@ class AccountProvider extends ChangeNotifier { locale: locale, )); if (outcome.account != null) { + _authState = AuthState.ready; _setResource(Resource(data: outcome.account, isLoading: false)); _pickupLocale(outcome.account!.locale); } else { @@ -98,10 +103,12 @@ class AccountProvider extends ChangeNotifier { } await VerificationService.requestLoginCode(pending); _pendingLogin = pending; + _authState = AuthState.idle; _setResource(_resource.copyWith(isLoading: false)); } return outcome; } catch (e) { + _authState = AuthState.error; _setResource(_resource.copyWith(isLoading: false, error: toException(e))); rethrow; } @@ -109,6 +116,7 @@ class AccountProvider extends ChangeNotifier { void completePendingLogin(Account account) { _pendingLogin = null; + _authState = AuthState.ready; _setResource(Resource(data: account, isLoading: false, error: null)); _pickupLocale(account.locale); } @@ -116,13 +124,17 @@ class AccountProvider extends ChangeNotifier { Future isAuthorizationStored() async => AuthorizationService.isAuthorizationStored(); Future restore() async { + _authState = AuthState.checking; + notifyListeners(); _setResource(_resource.copyWith(isLoading: true, error: null)); try { final acc = await AccountService.restore(); + _authState = AuthState.ready; _setResource(Resource(data: acc, isLoading: false)); _pickupLocale(acc.locale); return acc; } catch (e) { + _authState = AuthState.error; _setResource(_resource.copyWith(isLoading: false, error: toException(e))); rethrow; } @@ -154,11 +166,14 @@ class AccountProvider extends ChangeNotifier { } Future logout() async { + _authState = AuthState.empty; _setResource(_resource.copyWith(isLoading: true, error: null)); + _pendingLogin = null; try { await AccountService.logout(); _setResource(Resource(data: null, isLoading: false)); } catch (e) { + _authState = AuthState.error; _setResource(_resource.copyWith(isLoading: false, error: toException(e))); rethrow; } @@ -235,10 +250,14 @@ class AccountProvider extends ChangeNotifier { } } - Future restoreIfPossible() { - return _restoreFuture ??= AuthorizationService.isAuthorizationStored().then((hasAuth) async { - if (!hasAuth) return; - await restore(); - }); + Future restoreIfPossible() async { + if (_authState == AuthState.checking || _authState == AuthState.ready) return; + final hasAuth = await AuthorizationService.isAuthorizationStored(); + if (!hasAuth) { + _authState = AuthState.empty; + notifyListeners(); + return; + } + await restore(); } } diff --git a/frontend/pshared/lib/provider/organizations.dart b/frontend/pshared/lib/provider/organizations.dart index 0ea744e..1c8bd81 100644 --- a/frontend/pshared/lib/provider/organizations.dart +++ b/frontend/pshared/lib/provider/organizations.dart @@ -80,4 +80,12 @@ class OrganizationsProvider extends ChangeNotifier { notifyListeners(); return true; } + + Future reset() async { + _resource = Resource(data: []); + _currentOrg = null; + notifyListeners(); + // Best-effort cleanup of stored selection to avoid using stale org on next login. + await SecureStorageService.delete(Constants.currentOrgKey); + } } diff --git a/frontend/pshared/lib/provider/permissions.dart b/frontend/pshared/lib/provider/permissions.dart index 1e1209c..3e8e20f 100644 --- a/frontend/pshared/lib/provider/permissions.dart +++ b/frontend/pshared/lib/provider/permissions.dart @@ -52,7 +52,11 @@ class PermissionsProvider extends ChangeNotifier { } /// Load the [UserAccess] for the current venue. - Future load() async { + Future load() async { + if (!_organizations.isOrganizationSet) { + // Organization is not ready yet; skip loading until it becomes available. + return _userAccess.data; + } _userAccess = _userAccess.copyWith(isLoading: true, error: null); notifyListeners(); @@ -179,6 +183,10 @@ class PermissionsProvider extends ChangeNotifier { notifyListeners(); } + Future resetAsync() async { + reset(); + } + bool canRead(ResourceType r, {Object? objectRef}) => canAccessResource(r, action: perm.Action.read, objectRef: objectRef); bool canUpdate(ResourceType r, {Object? objectRef}) => canAccessResource(r, action: perm.Action.update, objectRef: objectRef); bool canDelete(ResourceType r, {Object? objectRef}) => canAccessResource(r, action: perm.Action.delete, objectRef: objectRef); diff --git a/frontend/pweb/lib/pages/loaders/account.dart b/frontend/pweb/lib/pages/loaders/account.dart index c159b57..68e957a 100644 --- a/frontend/pweb/lib/pages/loaders/account.dart +++ b/frontend/pweb/lib/pages/loaders/account.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:logging/logging.dart'; - import 'package:provider/provider.dart'; +import 'package:pshared/models/auth/state.dart'; import 'package:pshared/provider/account.dart'; import 'package:pweb/app/router/pages.dart'; @@ -12,57 +11,63 @@ import 'package:pweb/widgets/error/snackbar.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; -class AccountLoader extends StatelessWidget { +class AccountLoader extends StatefulWidget { final Widget child; const AccountLoader({super.key, required this.child}); - static final _logger = Logger('loader.account'); + @override + State createState() => _AccountLoaderState(); +} - void _notifyErrorAndRedirect(BuildContext context, Object error) { +class _AccountLoaderState extends State { + AuthState? _handledState; + + @override + void initState() { + super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted) return; - postNotifyUserOfErrorX( - context: context, - errorSituation: AppLocalizations.of(context)!.errorLogin, - exception: error, - ); - navigateAndReplace(context, Pages.login); + if (!mounted) return; + Provider.of(context, listen: false).restoreIfPossible(); }); } - void _restoreAndHandleResult(BuildContext context, AccountProvider provider) { - WidgetsBinding.instance.addPostFrameCallback((_) async { - try { - await provider.restoreIfPossible(); - } catch (error, stack) { - _logger.warning('Account restore failed: $error', error, stack); - } - if (!context.mounted) return; - final latest = Provider.of(context, listen: false); - if (latest.account != null) return; - if (latest.error != null) { - _notifyErrorAndRedirect(context, latest.error!); - return; - } - if (!latest.isLoading) { - navigateAndReplace(context, Pages.login); - } - }); + void _handleSideEffects(AccountProvider provider) { + if (_handledState == provider.authState) return; + _handledState = provider.authState; + + void goToLogin() { + if (!mounted) return; + navigateAndReplace(context, Pages.login); + } + + switch (provider.authState) { + case AuthState.error: + final error = provider.error ?? Exception('Authorization failed'); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + postNotifyUserOfErrorX( + context: context, + errorSituation: AppLocalizations.of(context)!.errorLogin, + exception: error, + ); + goToLogin(); + }); + break; + case AuthState.empty: + WidgetsBinding.instance.addPostFrameCallback((_) => goToLogin()); + break; + default: + break; + } } @override Widget build(BuildContext context) { return Consumer(builder: (context, provider, _) { - if (provider.account != null) return child; - if (provider.error != null) { - _notifyErrorAndRedirect(context, provider.error!); - return const Center(child: CircularProgressIndicator()); + _handleSideEffects(provider); + if (provider.authState == AuthState.ready && provider.account != null) { + return widget.child; } - if (!provider.isLoading) { - _restoreAndHandleResult(context, provider); - } - if (provider.isLoading) return const Center(child: CircularProgressIndicator()); - // In case of error or missing account we show loader while side effects handle navigation. return const Center(child: CircularProgressIndicator()); }); } diff --git a/frontend/pweb/lib/pages/loaders/organization.dart b/frontend/pweb/lib/pages/loaders/organization.dart index 3a3194f..c953377 100644 --- a/frontend/pweb/lib/pages/loaders/organization.dart +++ b/frontend/pweb/lib/pages/loaders/organization.dart @@ -4,9 +4,6 @@ import 'package:provider/provider.dart'; import 'package:pshared/provider/organizations.dart'; -import 'package:pweb/app/router/pages.dart'; -import 'package:pweb/widgets/error/snackbar.dart'; - import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -19,12 +16,20 @@ class OrganizationLoader extends StatelessWidget { Widget build(BuildContext context) => Consumer(builder: (context, provider, _) { if (provider.isLoading) return const Center(child: CircularProgressIndicator()); if (provider.error != null) { - postNotifyUserOfErrorX( - context: context, - errorSituation: AppLocalizations.of(context)!.errorLogin, - exception: provider.error!, + final loc = AppLocalizations.of(context)!; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(loc.errorLogin), + const SizedBox(height: 12), + ElevatedButton( + onPressed: provider.load, + child: Text(loc.retry), + ), + ], + ), ); - navigateAndReplace(context, Pages.login); } if ((provider.error == null) && (!provider.isOrganizationSet)) { WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/frontend/pweb/lib/pages/loaders/permissions.dart b/frontend/pweb/lib/pages/loaders/permissions.dart index 0db63cf..2c102d2 100644 --- a/frontend/pweb/lib/pages/loaders/permissions.dart +++ b/frontend/pweb/lib/pages/loaders/permissions.dart @@ -5,9 +5,6 @@ import 'package:provider/provider.dart'; import 'package:pshared/provider/account.dart'; import 'package:pshared/provider/permissions.dart'; -import 'package:pweb/app/router/pages.dart'; -import 'package:pweb/widgets/error/snackbar.dart'; - import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -15,26 +12,38 @@ class PermissionsLoader extends StatelessWidget { final Widget child; const PermissionsLoader({super.key, required this.child}); - void _notifyError(BuildContext context, Exception exception) { - WidgetsBinding.instance.addPostFrameCallback((_) { - postNotifyUserOfErrorX( - context: context, - errorSituation: AppLocalizations.of(context)!.errorLogin, - exception: exception, - ); - navigateAndReplace(context, Pages.login); - }); + void _triggerLoadIfNeeded(PermissionsProvider provider) { + if (!provider.isLoading && !provider.isReady) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!provider.isLoading && !provider.isReady) { + provider.load(); + } + }); + } } @override Widget build(BuildContext context) { return Consumer2( - builder: (context, provider, accountProvider, _) { - if (provider.isLoading) { - return const Center(child: CircularProgressIndicator()); - } + builder: (context, provider, _accountProvider, _) { if (provider.error != null) { - _notifyError(context, provider.error!); + final loc = AppLocalizations.of(context)!; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(loc.errorLogin), + const SizedBox(height: 12), + ElevatedButton( + onPressed: provider.load, + child: Text(loc.retry), + ), + ], + ), + ); + } + _triggerLoadIfNeeded(provider); + if (provider.isLoading || !provider.isReady) { return const Center(child: CircularProgressIndicator()); } return child; diff --git a/frontend/pweb/lib/providers/two_factor.dart b/frontend/pweb/lib/providers/two_factor.dart index 548244a..de821fe 100644 --- a/frontend/pweb/lib/providers/two_factor.dart +++ b/frontend/pweb/lib/providers/two_factor.dart @@ -17,6 +17,7 @@ class TwoFactorProvider extends ChangeNotifier { bool _hasError = false; bool _verificationSuccess = false; String? _errorMessage; + String? _currentPendingToken; bool get isSubmitting => _isSubmitting; bool get hasError => _hasError; @@ -26,6 +27,12 @@ class TwoFactorProvider extends ChangeNotifier { void update(AccountProvider accountProvider) { _accountProvider = accountProvider; + final pending = accountProvider.pendingLogin; + final token = pending?.pendingToken.token; + if (token != _currentPendingToken || accountProvider.account == null) { + _resetState(); + _currentPendingToken = token; + } } Future submitCode(String code) async { @@ -46,6 +53,7 @@ class TwoFactorProvider extends ChangeNotifier { ); _accountProvider.completePendingLogin(account); _verificationSuccess = true; + _currentPendingToken = null; } catch (e) { _hasError = true; _errorMessage = e.toString(); @@ -71,4 +79,17 @@ class TwoFactorProvider extends ChangeNotifier { notifyListeners(); } } -} \ No newline at end of file + + void reset() { + _resetState(); + _currentPendingToken = null; + } + + void _resetState() { + _isSubmitting = false; + _hasError = false; + _errorMessage = null; + _verificationSuccess = false; + notifyListeners(); + } +} diff --git a/frontend/pweb/lib/utils/logout.dart b/frontend/pweb/lib/utils/logout.dart index 97e030e..3f8693e 100644 --- a/frontend/pweb/lib/utils/logout.dart +++ b/frontend/pweb/lib/utils/logout.dart @@ -1,15 +1,31 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:pshared/provider/account.dart'; +import 'package:pshared/provider/organizations.dart'; import 'package:pshared/provider/permissions.dart'; import 'package:pweb/app/router/pages.dart'; +import 'package:pweb/providers/two_factor.dart'; -void logoutUtil(BuildContext context) { - context.read().logout(); - context.read().reset(); - navigateAndReplace(context, Pages.login); +Future logoutUtil(BuildContext context) async { + final accountProvider = context.read(); + final permissionsProvider = context.read(); + final organizationsProvider = context.read(); + final twoFactorProvider = context.read(); + await accountProvider.logout(); + permissionsProvider.reset(); + await organizationsProvider.reset(); + twoFactorProvider.reset(); + + final router = GoRouter.of(context); + final loginPath = routerPage(Pages.login); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + router.go(loginPath); + }); } diff --git a/frontend/pweb/lib/widgets/appbar/app_bar.dart b/frontend/pweb/lib/widgets/appbar/app_bar.dart index d5c8299..bb1da64 100644 --- a/frontend/pweb/lib/widgets/appbar/app_bar.dart +++ b/frontend/pweb/lib/widgets/appbar/app_bar.dart @@ -17,7 +17,7 @@ class PayoutAppBar extends StatelessWidget implements PreferredSizeWidget { final Widget title; final VoidCallback onAddFundsPressed; final List? actions; - final VoidCallback? onLogout; + final Future Function()? onLogout; final String? avatarUrl; @override diff --git a/frontend/pweb/lib/widgets/appbar/profile.dart b/frontend/pweb/lib/widgets/appbar/profile.dart index 365395c..b72a12b 100644 --- a/frontend/pweb/lib/widgets/appbar/profile.dart +++ b/frontend/pweb/lib/widgets/appbar/profile.dart @@ -7,7 +7,7 @@ class ProfileAvatar extends StatelessWidget { const ProfileAvatar({super.key, this.avatarUrl, this.onLogout}); final String? avatarUrl; - final VoidCallback? onLogout; + final Future Function()? onLogout; @override Widget build(BuildContext context) => PopupMenuButton( @@ -37,4 +37,4 @@ class ProfileAvatar extends StatelessWidget { child: avatarUrl == null ? const Icon(Icons.person, size: 24) : null, ), ); -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/widgets/drawer/tiles/logout.dart b/frontend/pweb/lib/widgets/drawer/tiles/logout.dart index fd4561a..d482df2 100644 --- a/frontend/pweb/lib/widgets/drawer/tiles/logout.dart +++ b/frontend/pweb/lib/widgets/drawer/tiles/logout.dart @@ -1,10 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import 'package:pshared/provider/account.dart'; - -import 'package:pweb/app/router/pages.dart'; +import 'package:pweb/utils/logout.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -23,10 +19,8 @@ class LogoutTile extends StatelessWidget { ); } - void _logout(BuildContext context) { + Future _logout(BuildContext context) async { Navigator.pop(context); - final accountProvider = Provider.of(context, listen: false); - accountProvider.logout(); - navigateAndReplace(context, Pages.login); + await logoutUtil(context); } } -- 2.49.1