From 811ec59f60d49b07caeab2249e371ffa98aa2071 Mon Sep 17 00:00:00 2001 From: alexdivadi Date: Wed, 18 Feb 2026 18:59:05 -0600 Subject: [PATCH 1/5] fix: routing and screen names" git push --- lib/core/config/router/router_utils.dart | 2 + lib/core/config/router/routes.dart | 17 +++--- .../{about_page.dart => about_screen.dart} | 55 ++++++++++--------- ...dart => notification_settings_screen.dart} | 4 +- .../settings/presentation/screens.dart | 4 +- .../presentation/settings_screen.dart | 4 +- 6 files changed, 46 insertions(+), 40 deletions(-) rename lib/core/features/settings/presentation/{about_page.dart => about_screen.dart} (57%) rename lib/core/features/settings/presentation/{notification_settings_page.dart => notification_settings_screen.dart} (89%) diff --git a/lib/core/config/router/router_utils.dart b/lib/core/config/router/router_utils.dart index dfbe89e..9b394b2 100644 --- a/lib/core/config/router/router_utils.dart +++ b/lib/core/config/router/router_utils.dart @@ -25,6 +25,8 @@ class RouterUtils { UserOnboardedGate.path, EventDetailScreen.path, SignAgreementScreen.path, + AboutScreen.path, + NotificationSettingsScreen.path, }; static const loginRoutes = {LoginScreen.path, SignUpScreen.path, ConfirmEmailScreen.path}; diff --git a/lib/core/config/router/routes.dart b/lib/core/config/router/routes.dart index 6d4db66..e11b905 100644 --- a/lib/core/config/router/routes.dart +++ b/lib/core/config/router/routes.dart @@ -62,8 +62,9 @@ final routes = [ name: UserOnboardedGate.name, pageBuilder: (context, state) { final redirectParam = state.uri.queryParameters['redirect']; - final redirectPath = - (redirectParam == null || redirectParam == '/') ? EventFeedScreen.pathAnonymous : redirectParam; + final redirectPath = (redirectParam == null || redirectParam == '/') + ? EventFeedScreen.pathAnonymous + : redirectParam; return NoTransitionPage(child: UserOnboardedGate(redirect: redirectPath)); }, ), @@ -226,13 +227,13 @@ final routes = [ builder: (context, state) => const ProfileScreen(), ), GoRoute( - path: AboutPage.path, - name: AboutPage.name, - builder: (context, state) => const AboutPage(), + path: AboutScreen.path, + name: AboutScreen.name, + builder: (context, state) => const AboutScreen(), ), GoRoute( - path: NotificationSettingsPage.path, - name: NotificationSettingsPage.name, - builder: (context, state) => const NotificationSettingsPage(), + path: NotificationSettingsScreen.path, + name: NotificationSettingsScreen.name, + builder: (context, state) => const NotificationSettingsScreen(), ), ]; diff --git a/lib/core/features/settings/presentation/about_page.dart b/lib/core/features/settings/presentation/about_screen.dart similarity index 57% rename from lib/core/features/settings/presentation/about_page.dart rename to lib/core/features/settings/presentation/about_screen.dart index 17e6c67..d15f4b6 100644 --- a/lib/core/features/settings/presentation/about_page.dart +++ b/lib/core/features/settings/presentation/about_screen.dart @@ -1,13 +1,14 @@ import 'package:anystep/core/common/constants/spacing.dart'; import 'package:anystep/core/common/utils/log_utils.dart'; +import 'package:anystep/core/common/widgets/max_width_container.dart'; import 'package:anystep/core/common/widgets/widgets.dart'; import 'package:anystep/l10n/generated/app_localizations.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; -class AboutPage extends StatelessWidget { - const AboutPage({super.key}); +class AboutScreen extends StatelessWidget { + const AboutScreen({super.key}); static const path = '/about'; static const name = 'about'; @@ -59,30 +60,32 @@ class AboutPage extends StatelessWidget { ), ], ), - body: ListView( - padding: const EdgeInsets.all(AnyStepSpacing.md16), - children: [ - Text(loc.aboutStoryTitle, style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: AnyStepSpacing.md16), - Text(loc.aboutStoryIntro, style: Theme.of(context).textTheme.bodyMedium), - const SizedBox(height: AnyStepSpacing.md16), - Text(loc.aboutStorySydneyTitle, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: AnyStepSpacing.sm8), - Text(loc.aboutStorySydneyBody, style: Theme.of(context).textTheme.bodyMedium), - const SizedBox(height: AnyStepSpacing.md16), - Text(loc.aboutStoryShermanTitle, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: AnyStepSpacing.sm8), - Text(loc.aboutStoryShermanBody, style: Theme.of(context).textTheme.bodyMedium), - const SizedBox(height: AnyStepSpacing.md16), - Text(loc.aboutStoryCostaRicaTitle, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: AnyStepSpacing.sm8), - Text(loc.aboutStoryCostaRicaBody, style: Theme.of(context).textTheme.bodyMedium), - const SizedBox(height: AnyStepSpacing.md16), - Text(loc.aboutStoryLocalTitle, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: AnyStepSpacing.sm8), - Text(loc.aboutStoryLocalBody, style: Theme.of(context).textTheme.bodyMedium), - SizedBox(height: AnyStepSpacing.xl64), - ], + body: MaxWidthContainer( + child: ListView( + padding: const EdgeInsets.all(AnyStepSpacing.md16), + children: [ + Text(loc.aboutStoryTitle, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: AnyStepSpacing.md16), + Text(loc.aboutStoryIntro, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: AnyStepSpacing.md16), + Text(loc.aboutStorySydneyTitle, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AnyStepSpacing.sm8), + Text(loc.aboutStorySydneyBody, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: AnyStepSpacing.md16), + Text(loc.aboutStoryShermanTitle, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AnyStepSpacing.sm8), + Text(loc.aboutStoryShermanBody, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: AnyStepSpacing.md16), + Text(loc.aboutStoryCostaRicaTitle, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AnyStepSpacing.sm8), + Text(loc.aboutStoryCostaRicaBody, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: AnyStepSpacing.md16), + Text(loc.aboutStoryLocalTitle, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AnyStepSpacing.sm8), + Text(loc.aboutStoryLocalBody, style: Theme.of(context).textTheme.bodyMedium), + SizedBox(height: AnyStepSpacing.xl64), + ], + ), ), ); } diff --git a/lib/core/features/settings/presentation/notification_settings_page.dart b/lib/core/features/settings/presentation/notification_settings_screen.dart similarity index 89% rename from lib/core/features/settings/presentation/notification_settings_page.dart rename to lib/core/features/settings/presentation/notification_settings_screen.dart index 0624f63..d3cef7b 100644 --- a/lib/core/features/settings/presentation/notification_settings_page.dart +++ b/lib/core/features/settings/presentation/notification_settings_screen.dart @@ -4,8 +4,8 @@ import 'package:anystep/core/features/notifications/presentation/event_notificat import 'package:anystep/l10n/generated/app_localizations.dart'; import 'package:flutter/material.dart'; -class NotificationSettingsPage extends StatelessWidget { - const NotificationSettingsPage({super.key}); +class NotificationSettingsScreen extends StatelessWidget { + const NotificationSettingsScreen({super.key}); static const path = '/settings/notifications'; static const name = 'notification-settings'; diff --git a/lib/core/features/settings/presentation/screens.dart b/lib/core/features/settings/presentation/screens.dart index 274e974..8cd3f23 100644 --- a/lib/core/features/settings/presentation/screens.dart +++ b/lib/core/features/settings/presentation/screens.dart @@ -1,3 +1,3 @@ export 'settings_screen.dart'; -export 'notification_settings_page.dart'; -export 'about_page.dart'; +export 'notification_settings_screen.dart'; +export 'about_screen.dart'; diff --git a/lib/core/features/settings/presentation/settings_screen.dart b/lib/core/features/settings/presentation/settings_screen.dart index 8d4f23d..fa81535 100644 --- a/lib/core/features/settings/presentation/settings_screen.dart +++ b/lib/core/features/settings/presentation/settings_screen.dart @@ -35,7 +35,7 @@ class SettingsScreen extends ConsumerWidget { leading: const Icon(Icons.info_outline), title: Text(loc.aboutTitle), trailing: const Icon(Icons.chevron_right), - onTap: () => context.push(AboutPage.path), + onTap: () => context.push(AboutScreen.path), ), const ThemeModeSetting(), const LocaleSetting(), @@ -43,7 +43,7 @@ class SettingsScreen extends ConsumerWidget { leading: const Icon(Icons.notifications), title: Text(loc.notificationSettingsTitle), trailing: const Icon(Icons.chevron_right), - onTap: () => context.push(NotificationSettingsPage.path), + onTap: () => context.push(NotificationSettingsScreen.path), ), if (isAuth != null) ...[ ListTile( From e1260918427fd4a31be0b1c3db27674910e23357 Mon Sep 17 00:00:00 2001 From: alexdivadi Date: Thu, 19 Feb 2026 22:25:08 -0600 Subject: [PATCH 2/5] refactor: address selector --- lib/core/app_startup/app_startup.dart | 6 +- lib/core/app_startup/app_startup.g.dart | 2 +- lib/core/common/utils/state_utils.dart | 25 ++ .../inputs/address_autocomplete_field.dart | 3 +- .../widgets/inputs/address_modal_tile.dart | 317 ++++++++++++++++++ .../inputs/any_step_address_field.dart | 70 ++-- lib/core/common/widgets/inputs/inputs.dart | 1 + .../config/remote_config/remote_config.dart | 37 ++ .../config/remote_config/remote_config.g.dart | 51 +++ lib/core/config/router/router_utils.dart | 1 + lib/core/config/router/routes.dart | 5 + .../event_create/event_create_screen.dart | 36 ++ .../event_detail/event_detail_form.dart | 18 +- .../event_feed/event_feed_screen.dart | 92 ++++- .../features/events/presentation/screens.dart | 1 + .../location/data/places_api_client.dart | 79 +---- .../location/domain/places_models.dart | 77 ++--- .../location/utils/place_to_address.dart | 38 +-- .../onboarding/onboarding_screen.dart | 5 +- .../presentation/profile/profile_form.dart | 11 +- lib/core/shared_prefs/shared_prefs.dart | 6 + lib/l10n/app_en.arb | 6 + lib/l10n/app_es.arb | 6 + lib/l10n/generated/app_localizations.dart | 18 + lib/l10n/generated/app_localizations_en.dart | 9 + lib/l10n/generated/app_localizations_es.dart | 9 + pubspec.lock | 8 + pubspec.yaml | 1 + 28 files changed, 738 insertions(+), 200 deletions(-) create mode 100644 lib/core/common/utils/state_utils.dart create mode 100644 lib/core/common/widgets/inputs/address_modal_tile.dart create mode 100644 lib/core/config/remote_config/remote_config.dart create mode 100644 lib/core/config/remote_config/remote_config.g.dart create mode 100644 lib/core/features/events/presentation/event_create/event_create_screen.dart diff --git a/lib/core/app_startup/app_startup.dart b/lib/core/app_startup/app_startup.dart index ab0946b..864559b 100644 --- a/lib/core/app_startup/app_startup.dart +++ b/lib/core/app_startup/app_startup.dart @@ -1,4 +1,5 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:anystep/core/config/remote_config/remote_config.dart'; import 'package:anystep/core/shared_prefs/shared_prefs.dart'; part 'app_startup.g.dart'; @@ -8,5 +9,8 @@ Future appStartup(Ref ref) async { ref.onDispose(() { ref.invalidate(appPreferencesProvider); }); - await Future.wait([ref.watch(appPreferencesProvider.future)]); + await Future.wait([ + ref.watch(appPreferencesProvider.future), + ref.watch(remoteConfigProvider.future), + ]); } diff --git a/lib/core/app_startup/app_startup.g.dart b/lib/core/app_startup/app_startup.g.dart index 6ac0a9d..93731a0 100644 --- a/lib/core/app_startup/app_startup.g.dart +++ b/lib/core/app_startup/app_startup.g.dart @@ -40,4 +40,4 @@ final class AppStartupProvider } } -String _$appStartupHash() => r'90afd3c1f084778e9ba23ad0fd4dd60095483743'; +String _$appStartupHash() => r'f4586cdae54adff2579468604f30cf17298e861b'; diff --git a/lib/core/common/utils/state_utils.dart b/lib/core/common/utils/state_utils.dart new file mode 100644 index 0000000..0654529 --- /dev/null +++ b/lib/core/common/utils/state_utils.dart @@ -0,0 +1,25 @@ +import 'package:anystep/core/common/data/json_data.dart'; + +String? normalizeUsState(String? input) { + if (input == null) return null; + final trimmed = input.trim(); + if (trimmed.isEmpty) return null; + + final lettersOnly = trimmed.replaceAll(RegExp(r'[^A-Za-z]'), ''); + if (lettersOnly.length == 2) { + final upper = lettersOnly.toUpperCase(); + return states.containsKey(upper) ? upper : null; + } + + final normalizedInput = _normalizeAlpha(trimmed); + for (final entry in states.entries) { + if (_normalizeAlpha(entry.value) == normalizedInput) { + return entry.key; + } + } + return null; +} + +bool isValidUsState(String? input) => normalizeUsState(input) != null; + +String _normalizeAlpha(String value) => value.replaceAll(RegExp(r'[^A-Za-z]'), '').toLowerCase(); diff --git a/lib/core/common/widgets/inputs/address_autocomplete_field.dart b/lib/core/common/widgets/inputs/address_autocomplete_field.dart index ba738e9..5b390f6 100644 --- a/lib/core/common/widgets/inputs/address_autocomplete_field.dart +++ b/lib/core/common/widgets/inputs/address_autocomplete_field.dart @@ -100,8 +100,7 @@ class _AddressAutocompleteFieldState extends ConsumerState formKey; + final int? initialAddressId; + final String addressIdFieldName; + final String? title; + final String countryCode; + final bool enabled; + final bool disableSearch; + final bool includeEventAddresses; + final bool includeUserAddresses; + final bool isUserAddress; + final bool showNameField; + final String nameFieldName; + final String? nameLabelText; + final String? Function(String?)? nameValidator; + final String postalCodeFieldName; + final String? streetLabelText; + final String? streetSecondaryLabelText; + final String? cityLabelText; + final String? stateLabelText; + final String? postalCodeLabelText; + final String? Function(String?)? streetValidator; + final String? Function(String?)? streetSecondaryValidator; + final String? Function(String?)? cityValidator; + final String? Function(String?)? stateValidator; + final String? Function(String?)? postalCodeValidator; + final String? Function(dynamic)? addressIdValidator; + final ValueChanged? onAddressSaved; + + @override + ConsumerState createState() => _AnyStepAddressModalTileState(); +} + +class _AnyStepAddressModalTileState extends ConsumerState { + int? _addressId; + AddressModel? _address; + + @override + void initState() { + super.initState(); + _addressId = widget.initialAddressId; + if (_addressId != null) { + _loadAddress(_addressId!); + } + } + + @override + void didUpdateWidget(covariant AnyStepAddressModalTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialAddressId != oldWidget.initialAddressId) { + _addressId = widget.initialAddressId; + widget.formKey.currentState?.fields[widget.addressIdFieldName]?.didChange(_addressId); + if (_addressId != null) { + _loadAddress(_addressId!); + } else { + setState(() => _address = null); + } + } + } + + Future _loadAddress(int id) async { + try { + final repo = ref.read(addressRepositoryProvider); + final address = await repo.get(documentId: id.toString()); + if (!mounted) return; + setState(() => _address = address); + } catch (_) { + if (!mounted) return; + setState(() => _address = null); + } + } + + void _handleAddressSaved(int? addressId) { + if (addressId == null) return; + setState(() { + _addressId = addressId; + }); + final field = widget.formKey.currentState?.fields[widget.addressIdFieldName]; + field?.didChange(addressId); + widget.onAddressSaved?.call(addressId); + _loadAddress(addressId); + } + + void _openModal() { + if (!widget.enabled) return; + context.showModal( + _AddressModalContent( + title: widget.title, + initialAddressId: _addressId, + countryCode: widget.countryCode, + disableSearch: widget.disableSearch, + includeEventAddresses: widget.includeEventAddresses, + includeUserAddresses: widget.includeUserAddresses, + isUserAddress: widget.isUserAddress, + showNameField: widget.showNameField, + nameFieldName: widget.nameFieldName, + nameLabelText: widget.nameLabelText, + nameValidator: widget.nameValidator, + postalCodeFieldName: widget.postalCodeFieldName, + streetLabelText: widget.streetLabelText, + streetSecondaryLabelText: widget.streetSecondaryLabelText, + cityLabelText: widget.cityLabelText, + stateLabelText: widget.stateLabelText, + postalCodeLabelText: widget.postalCodeLabelText, + streetValidator: widget.streetValidator, + streetSecondaryValidator: widget.streetSecondaryValidator, + cityValidator: widget.cityValidator, + stateValidator: widget.stateValidator, + postalCodeValidator: widget.postalCodeValidator, + onAddressSaved: _handleAddressSaved, + ), + isScrollControlled: true, + ); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + return FormBuilderField( + name: widget.addressIdFieldName, + initialValue: _addressId, + validator: widget.addressIdValidator, + builder: (field) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(widget.title ?? loc.address), + subtitle: Text(_address?.formattedAddress ?? loc.noAddressProvided), + trailing: const Icon(Icons.chevron_right), + onTap: widget.enabled ? _openModal : null, + ), + if (field.errorText != null) + Padding( + padding: const EdgeInsets.only(bottom: AnyStepSpacing.sm8), + child: Text( + field.errorText ?? '', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), + ); + } +} + +class _AddressModalContent extends ConsumerStatefulWidget { + const _AddressModalContent({ + required this.onAddressSaved, + this.title, + this.initialAddressId, + this.countryCode = 'US', + this.disableSearch = false, + this.includeEventAddresses = true, + this.includeUserAddresses = true, + this.isUserAddress = false, + this.showNameField = false, + this.nameFieldName = 'addressName', + this.nameLabelText, + this.nameValidator, + this.postalCodeFieldName = 'postalCode', + this.streetLabelText, + this.streetSecondaryLabelText, + this.cityLabelText, + this.stateLabelText, + this.postalCodeLabelText, + this.streetValidator, + this.streetSecondaryValidator, + this.cityValidator, + this.stateValidator, + this.postalCodeValidator, + }); + + final ValueChanged onAddressSaved; + final String? title; + final int? initialAddressId; + final String countryCode; + final bool disableSearch; + final bool includeEventAddresses; + final bool includeUserAddresses; + final bool isUserAddress; + final bool showNameField; + final String nameFieldName; + final String? nameLabelText; + final String? Function(String?)? nameValidator; + final String postalCodeFieldName; + final String? streetLabelText; + final String? streetSecondaryLabelText; + final String? cityLabelText; + final String? stateLabelText; + final String? postalCodeLabelText; + final String? Function(String?)? streetValidator; + final String? Function(String?)? streetSecondaryValidator; + final String? Function(String?)? cityValidator; + final String? Function(String?)? stateValidator; + final String? Function(String?)? postalCodeValidator; + + @override + ConsumerState<_AddressModalContent> createState() => _AddressModalContentState(); +} + +class _AddressModalContentState extends ConsumerState<_AddressModalContent> { + final _formKey = GlobalKey(); + + void _handleSaved(int? id) { + if (id == null) return; + widget.onAddressSaved(id); + context.pop(); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AnyStepSpacing.md16, + AnyStepSpacing.sm8, + AnyStepSpacing.md16, + AnyStepSpacing.md16, + ), + child: SingleChildScrollView( + child: FormBuilder( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + widget.title ?? loc.address, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + IconButton( + onPressed: () => context.pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: AnyStepSpacing.sm8), + AnyStepAddressField( + formKey: _formKey, + initialAddressId: widget.initialAddressId, + countryCode: widget.countryCode, + disableSearch: widget.disableSearch, + includeEventAddresses: widget.includeEventAddresses, + includeUserAddresses: widget.includeUserAddresses, + isUserAddress: widget.isUserAddress, + showNameField: widget.showNameField, + nameFieldName: widget.nameFieldName, + nameLabelText: widget.nameLabelText, + nameValidator: widget.nameValidator, + postalCodeFieldName: widget.postalCodeFieldName, + streetLabelText: widget.streetLabelText, + streetSecondaryLabelText: widget.streetSecondaryLabelText, + cityLabelText: widget.cityLabelText, + stateLabelText: widget.stateLabelText, + postalCodeLabelText: widget.postalCodeLabelText, + streetValidator: widget.streetValidator, + streetSecondaryValidator: widget.streetSecondaryValidator, + cityValidator: widget.cityValidator, + stateValidator: widget.stateValidator, + postalCodeValidator: widget.postalCodeValidator, + onAddressSaved: _handleSaved, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/core/common/widgets/inputs/any_step_address_field.dart b/lib/core/common/widgets/inputs/any_step_address_field.dart index 9cee5f7..ee721c0 100644 --- a/lib/core/common/widgets/inputs/any_step_address_field.dart +++ b/lib/core/common/widgets/inputs/any_step_address_field.dart @@ -2,21 +2,19 @@ import 'dart:async'; import 'package:anystep/core/common/constants/spacing.dart'; import 'package:anystep/core/common/utils/log_utils.dart'; +import 'package:anystep/core/common/utils/state_utils.dart'; import 'package:anystep/core/common/widgets/inputs/any_step_text_field.dart'; -import 'package:anystep/core/config/theme/colors.dart'; import 'package:anystep/core/features/location/data/address_repository.dart'; import 'package:anystep/core/features/location/data/places_api_client.dart'; import 'package:anystep/core/features/location/domain/address_model.dart'; import 'package:anystep/core/features/location/domain/places_models.dart'; import 'package:anystep/core/features/location/utils/place_to_address.dart'; import 'package:anystep/l10n/generated/app_localizations.dart'; -import 'package:anystep/env/env.dart'; import 'package:flutter/material.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:form_builder_validators/form_builder_validators.dart'; -// TODO: Refactor this widget to separate address search and address form into separate widgets class AnyStepAddressField extends ConsumerStatefulWidget { const AnyStepAddressField({ super.key, @@ -31,6 +29,10 @@ class AnyStepAddressField extends ConsumerStatefulWidget { this.addressIdFieldName = 'addressId', this.onAddressSaved, this.showSaveButton = true, + this.showNameField = false, + this.nameFieldName = 'addressName', + this.nameLabelText, + this.nameValidator, this.streetLabelText, this.streetSecondaryLabelText, this.cityLabelText, @@ -60,6 +62,10 @@ class AnyStepAddressField extends ConsumerStatefulWidget { final String addressIdFieldName; final ValueChanged? onAddressSaved; final bool showSaveButton; + final bool showNameField; + final String nameFieldName; + final String? nameLabelText; + final String? Function(String?)? nameValidator; final String? streetLabelText; final String? streetSecondaryLabelText; final String? cityLabelText; @@ -108,7 +114,7 @@ class _AnyStepAddressFieldState extends ConsumerState { super.initState(); _streetController.text = widget.initialStreet ?? ''; _streetFocusNode.addListener(_handleFocusChange); - _isSearchDisabled = widget.disableSearch || Env.placesApiKey.isEmpty; + _isSearchDisabled = widget.disableSearch; _addressId = widget.initialAddressId; if (widget.initialAddressId != null) { WidgetsBinding.instance.addPostFrameCallback((_) => _loadInitialAddress()); @@ -120,8 +126,6 @@ class _AnyStepAddressFieldState extends ConsumerState { super.didUpdateWidget(oldWidget); if (widget.disableSearch && !_isSearchDisabled) { _isSearchDisabled = true; - } else if (Env.placesApiKey.isEmpty) { - _isSearchDisabled = true; } } @@ -203,7 +207,7 @@ class _AnyStepAddressFieldState extends ConsumerState { try { predictions = await ref .read(placesApiClientProvider) - .autocomplete(query, countryCode: widget.countryCode); + .autocomplete(query, countryCode: widget.countryCode, limit: 5); } catch (e) { _isSearchDisabled = true; _error = e.toString(); @@ -227,17 +231,27 @@ class _AnyStepAddressFieldState extends ConsumerState { }); } + String? _validateState(String? value, AppLocalizations loc) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) return loc.validatorState; + if (!isValidUsState(trimmed)) return loc.validatorState; + return null; + } + void _selectAddress(AddressModel address) { final form = widget.formKey.currentState; if (form == null) return; _isApplyingSelection = true; + if (widget.showNameField) { + form.fields[widget.nameFieldName]?.didChange(address.name); + } form.fields['street']?.didChange(address.street); form.fields['streetSecondary']?.didChange(address.streetSecondary); form.fields['city']?.didChange(address.city); form.fields['state']?.didChange(address.state); form.fields[widget.postalCodeFieldName]?.didChange(address.postalCode); _placeId = address.placeId; - _placeName = address.name ?? address.formattedAddress; + _placeName = address.name; _latitude = address.latitude; _longitude = address.longitude; _addressId = address.id; @@ -268,8 +282,7 @@ class _AnyStepAddressFieldState extends ConsumerState { _error = null; }); try { - final details = await ref.read(placesApiClientProvider).placeDetails(prediction.placeId); - final parsed = placeDetailsToAddress(details); + final parsed = placeDetailsToAddress(prediction.details); final form = widget.formKey.currentState; if (form == null) return; form.fields['street']?.didChange(parsed.street); @@ -278,7 +291,6 @@ class _AnyStepAddressFieldState extends ConsumerState { form.fields['state']?.didChange(parsed.state); form.fields[widget.postalCodeFieldName]?.didChange(parsed.postalCode); _placeId = parsed.placeId; - _placeName = parsed.name ?? prediction.description; _latitude = parsed.latitude; _longitude = parsed.longitude; _isApplyingSelection = true; @@ -312,8 +324,10 @@ class _AnyStepAddressFieldState extends ConsumerState { final repo = ref.read(addressRepositoryProvider); final address = await repo.get(documentId: widget.initialAddressId.toString()); _placeId = address.placeId; - // TODO: add name field to form _placeName = address.name; + if (widget.showNameField) { + form.fields[widget.nameFieldName]?.didChange(address.name); + } _latitude = address.latitude; _longitude = address.longitude; form.fields['street']?.didChange(address.street); @@ -331,9 +345,15 @@ class _AnyStepAddressFieldState extends ConsumerState { Future _saveAddressIfComplete() async { final form = widget.formKey.currentState; if (form == null) return; + final nameValue = form.fields[widget.nameFieldName]?.value?.toString().trim(); final street = form.fields['street']?.value?.toString().trim() ?? ''; final city = form.fields['city']?.value?.toString().trim() ?? ''; - final state = form.fields['state']?.value?.toString().trim() ?? ''; + final rawState = form.fields['state']?.value?.toString().trim() ?? ''; + final normalizedState = normalizeUsState(rawState); + final state = normalizedState ?? rawState; + if (normalizedState != null && normalizedState != rawState) { + form.fields['state']?.didChange(normalizedState); + } final postal = form.fields[widget.postalCodeFieldName]?.value?.toString().trim() ?? ''; if (street.isEmpty || city.isEmpty || state.isEmpty || postal.isEmpty) return; final address = AddressModel.withGeohash( @@ -347,7 +367,7 @@ class _AnyStepAddressFieldState extends ConsumerState { latitude: _latitude, longitude: _longitude, placeId: _placeId, - name: _placeName, + name: (nameValue != null && nameValue.isNotEmpty) ? nameValue : _placeName, ); try { setState(() => _isSaving = true); @@ -369,18 +389,14 @@ class _AnyStepAddressFieldState extends ConsumerState { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Icon(Icons.warning_amber_rounded, color: AnyStepColors.warning, size: 16), - Expanded( - child: Text( - "An improved address input is coming soon.", - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], - ), - const SizedBox(height: AnyStepSpacing.sm8), + if (widget.showNameField) ...[ + AnyStepTextField( + name: widget.nameFieldName, + labelText: widget.nameLabelText ?? "${loc.nameLabel} (${loc.optional})", + validator: widget.nameValidator, + ), + const SizedBox(height: AnyStepSpacing.sm4), + ], AnyStepTextField( name: 'street', labelText: widget.streetLabelText ?? loc.streetAddress, @@ -481,7 +497,7 @@ class _AnyStepAddressFieldState extends ConsumerState { name: 'state', initialValue: widget.initialState, labelText: widget.stateLabelText ?? loc.state, - validator: widget.stateValidator ?? FormBuilderValidators.state(), + validator: widget.stateValidator ?? (value) => _validateState(value, loc), focusNode: _stateFocusNode, ), ), diff --git a/lib/core/common/widgets/inputs/inputs.dart b/lib/core/common/widgets/inputs/inputs.dart index f29b859..af19ea5 100644 --- a/lib/core/common/widgets/inputs/inputs.dart +++ b/lib/core/common/widgets/inputs/inputs.dart @@ -3,5 +3,6 @@ export 'any_step_text_field.dart'; export 'any_step_date_time_picker.dart'; export 'any_step_switch_input.dart'; export 'any_step_address_field.dart'; +export 'address_modal_tile.dart'; export 'address_autocomplete_field.dart'; export 'image_upload_widget.dart'; diff --git a/lib/core/config/remote_config/remote_config.dart b/lib/core/config/remote_config/remote_config.dart new file mode 100644 index 0000000..8483b33 --- /dev/null +++ b/lib/core/config/remote_config/remote_config.dart @@ -0,0 +1,37 @@ +import 'package:anystep/core/common/utils/log_utils.dart'; +import 'package:firebase_remote_config/firebase_remote_config.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'remote_config.g.dart'; + +class RemoteConfigKeys { + const RemoteConfigKeys._(); + + static const String welcomeMessage = 'welcomeMessage'; +} + +class RemoteConfigService { + RemoteConfigService(this._remoteConfig); + + final FirebaseRemoteConfig _remoteConfig; + + String get welcomeMessage => _remoteConfig.getString(RemoteConfigKeys.welcomeMessage).trim(); +} + +@Riverpod(keepAlive: true) +Future remoteConfig(Ref ref) async { + final remoteConfig = FirebaseRemoteConfig.instance; + try { + await remoteConfig.setConfigSettings( + RemoteConfigSettings( + fetchTimeout: const Duration(seconds: 10), + minimumFetchInterval: const Duration(hours: 1), + ), + ); + await remoteConfig.setDefaults({RemoteConfigKeys.welcomeMessage: ''}); + await remoteConfig.fetchAndActivate(); + } catch (e, st) { + Log.w('Remote config fetch failed', e, st); + } + return RemoteConfigService(remoteConfig); +} diff --git a/lib/core/config/remote_config/remote_config.g.dart b/lib/core/config/remote_config/remote_config.g.dart new file mode 100644 index 0000000..aec6b98 --- /dev/null +++ b/lib/core/config/remote_config/remote_config.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'remote_config.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(remoteConfig) +const remoteConfigProvider = RemoteConfigProvider._(); + +final class RemoteConfigProvider + extends + $FunctionalProvider< + AsyncValue, + RemoteConfigService, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + const RemoteConfigProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'remoteConfigProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$remoteConfigHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return remoteConfig(ref); + } +} + +String _$remoteConfigHash() => r'12035af3a07084603213aa4e29910520e5b49f8d'; diff --git a/lib/core/config/router/router_utils.dart b/lib/core/config/router/router_utils.dart index 9b394b2..3fa2be5 100644 --- a/lib/core/config/router/router_utils.dart +++ b/lib/core/config/router/router_utils.dart @@ -11,6 +11,7 @@ class RouterUtils { EventFeedScreen.pathAdmin, SettingsScreen.pathAdmin, ReportsScreen.pathAdmin, + CreateEventScreen.path, }; static const anyRoutes = { diff --git a/lib/core/config/router/routes.dart b/lib/core/config/router/routes.dart index e11b905..b1142da 100644 --- a/lib/core/config/router/routes.dart +++ b/lib/core/config/router/routes.dart @@ -198,6 +198,11 @@ final routes = [ ), ], ), + GoRoute( + path: CreateEventScreen.path, + name: CreateEventScreen.name, + builder: (context, state) => const CreateEventScreen(), + ), GoRoute( path: EventDetailScreen.path, name: EventDetailScreen.name, diff --git a/lib/core/features/events/presentation/event_create/event_create_screen.dart b/lib/core/features/events/presentation/event_create/event_create_screen.dart new file mode 100644 index 0000000..6dfe726 --- /dev/null +++ b/lib/core/features/events/presentation/event_create/event_create_screen.dart @@ -0,0 +1,36 @@ +import 'package:anystep/core/common/utils/snackbar_message.dart'; +import 'package:anystep/core/common/widgets/any_step_app_bar.dart'; +import 'package:anystep/core/common/widgets/any_step_scaffold.dart'; +import 'package:anystep/core/common/widgets/max_width_container.dart'; +import 'package:anystep/core/features/events/presentation/event_detail/event_detail_form.dart'; +import 'package:anystep/l10n/generated/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class CreateEventScreen extends StatelessWidget { + const CreateEventScreen({super.key}); + + static const path = '/events/create'; + static const name = 'event-create'; + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + return AnyStepScaffold( + appBar: AnyStepAppBar(title: Text(loc.createEvent)), + body: MaxWidthContainer( + child: SafeArea( + child: EventDetailForm( + physics: const AlwaysScrollableScrollPhysics(), + showAddressNameField: true, + onSuccess: () { + if (!context.mounted) return; + context.showSuccessSnackbar(loc.eventCreated); + context.pop(); + }, + ), + ), + ), + ); + } +} diff --git a/lib/core/features/events/presentation/event_detail/event_detail_form.dart b/lib/core/features/events/presentation/event_detail/event_detail_form.dart index 5ba0d0e..5fb9c47 100644 --- a/lib/core/features/events/presentation/event_detail/event_detail_form.dart +++ b/lib/core/features/events/presentation/event_detail/event_detail_form.dart @@ -10,11 +10,18 @@ import 'package:form_builder_validators/form_builder_validators.dart'; import 'package:image_picker/image_picker.dart'; class EventDetailForm extends ConsumerStatefulWidget { - const EventDetailForm({super.key, this.event, this.onSuccess, this.physics}); + const EventDetailForm({ + super.key, + this.event, + this.onSuccess, + this.physics, + this.showAddressNameField = false, + }); final EventModel? event; final VoidCallback? onSuccess; final ScrollPhysics? physics; + final bool showAddressNameField; @override ConsumerState createState() => _EventDetailFormState(); @@ -136,21 +143,18 @@ class _EventDetailFormState extends ConsumerState { ), const SizedBox(height: AnyStepSpacing.sm4), - Align( - alignment: Alignment.centerLeft, - child: Text(loc.address, style: Theme.of(context).textTheme.titleMedium), - ), - const SizedBox(height: AnyStepSpacing.sm4), - AnyStepAddressField( + AnyStepAddressModalTile( formKey: formKey, initialAddressId: widget.event?.addressId ?? widget.event?.address?.id, isUserAddress: false, includeEventAddresses: true, includeUserAddresses: false, + showNameField: widget.showAddressNameField, streetValidator: FormBuilderValidators.required(), streetSecondaryValidator: FormBuilderValidators.street( checkNullOrEmpty: false, ), + addressIdValidator: FormBuilderValidators.required(), ), Theme( data: Theme.of(context).copyWith(dividerColor: Colors.transparent), diff --git a/lib/core/features/events/presentation/event_feed/event_feed_screen.dart b/lib/core/features/events/presentation/event_feed/event_feed_screen.dart index 7079a69..989e664 100644 --- a/lib/core/features/events/presentation/event_feed/event_feed_screen.dart +++ b/lib/core/features/events/presentation/event_feed/event_feed_screen.dart @@ -1,6 +1,5 @@ import 'package:anystep/core/common/constants/spacing.dart'; import 'package:anystep/core/common/constants/breakpoints.dart'; -import 'package:anystep/core/common/utils/snackbar_message.dart'; import 'package:anystep/core/common/widgets/widgets.dart'; import 'package:anystep/core/features/dashboard/presentation/widgets/dashboard_calendar_card.dart'; import 'package:anystep/core/features/dashboard/presentation/widgets/dashboard_metrics_card.dart'; @@ -9,12 +8,14 @@ import 'package:anystep/core/features/dashboard/presentation/widgets/recent_even import 'package:anystep/core/features/dashboard/presentation/widgets/upcoming_events_list.dart'; import 'package:anystep/core/features/auth/data/auth_repository.dart'; import 'package:anystep/core/features/auth/presentation/login/login_screen.dart'; -import 'package:anystep/core/features/events/presentation/event_detail/event_detail_form.dart'; +import 'package:anystep/core/features/events/presentation/event_create/event_create_screen.dart'; import 'package:anystep/core/features/events/presentation/event_feed/widgets/search_events_feed.dart'; import 'package:anystep/core/features/events/data/event_repository.dart'; import 'package:anystep/core/features/profile/data/current_user.dart'; import 'package:anystep/core/features/profile/domain/user_role.dart'; import 'package:anystep/core/features/reports/data/volunteer_hours_providers.dart'; +import 'package:anystep/core/config/remote_config/remote_config.dart'; +import 'package:anystep/core/shared_prefs/shared_prefs.dart'; import 'package:anystep/l10n/generated/app_localizations.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -38,6 +39,39 @@ class EventFeedScreen extends ConsumerStatefulWidget { class _EventFeedScreenState extends ConsumerState { bool isSearching = false; String q = ''; + bool _welcomeChecked = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _maybeShowWelcomeMessage()); + } + + Future _maybeShowWelcomeMessage() async { + if (_welcomeChecked) return; + _welcomeChecked = true; + + final prefs = await ref.read(appPreferencesProvider.future); + if (prefs.getWelcomeMessageSeen()) return; + + final remoteConfig = await ref.read(remoteConfigProvider.future); + final message = remoteConfig.welcomeMessage; + if (message.isEmpty) return; + if (!mounted) return; + + await prefs.setWelcomeMessageSeen(); + context.showModal( + _WelcomeMessageModal( + message: message, + onDismissed: () async { + if (context.mounted) { + context.pop(); + } + }, + ), + isScrollControlled: true, + ); + } Future _refreshDashboard(bool includeReports) async { ref.invalidate(getEventsProvider); @@ -102,9 +136,9 @@ class _EventFeedScreenState extends ConsumerState { const SizedBox(width: 8), Text(loc.login), ], - ), - ), - ), + ), + ), + ), ] : null, bottom: PreferredSize( @@ -244,17 +278,7 @@ class _EventFeedScreenState extends ConsumerState { floatingActionButton: !isSearching && isAuthenticated && user.value!.role.canCreateEvent ? AnyStepFab( heroTag: 'create_event_fab', - onPressed: () => context.showModal( - EventDetailForm( - physics: const AlwaysScrollableScrollPhysics(), - onSuccess: () { - if (context.mounted) { - context.showSuccessSnackbar(loc.eventCreated); - context.pop(); - } - }, - ), - ), + onPressed: () => context.push(CreateEventScreen.path), icon: Icons.add, tooltip: loc.createEvent, ) @@ -284,3 +308,39 @@ class _EventFeedScreenState extends ConsumerState { ); } } + +class _WelcomeMessageModal extends StatelessWidget { + const _WelcomeMessageModal({required this.message, required this.onDismissed}); + + final String message; + final VoidCallback onDismissed; + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + return ListView( + shrinkWrap: true, + padding: const EdgeInsets.all(AnyStepSpacing.md16), + children: [ + Text( + loc.welcomeMessageTitle, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: AnyStepSpacing.sm8), + Text( + message, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: AnyStepSpacing.md24), + Align( + alignment: Alignment.centerRight, + child: ElevatedButton( + onPressed: onDismissed, + child: Text(loc.welcomeMessageDismiss), + ), + ), + const SizedBox(height: AnyStepSpacing.sm8), + ], + ); + } +} diff --git a/lib/core/features/events/presentation/screens.dart b/lib/core/features/events/presentation/screens.dart index e208b58..e86a8d3 100644 --- a/lib/core/features/events/presentation/screens.dart +++ b/lib/core/features/events/presentation/screens.dart @@ -1,2 +1,3 @@ export 'event_feed/event_feed_screen.dart'; export 'event_detail/event_detail_screen.dart'; +export 'event_create/event_create_screen.dart'; diff --git a/lib/core/features/location/data/places_api_client.dart b/lib/core/features/location/data/places_api_client.dart index 2123c13..dbb91ad 100644 --- a/lib/core/features/location/data/places_api_client.dart +++ b/lib/core/features/location/data/places_api_client.dart @@ -1,85 +1,32 @@ import 'package:anystep/core/common/utils/log_utils.dart'; import 'package:anystep/core/features/location/domain/places_models.dart'; -import 'package:anystep/env/env.dart'; -import 'package:dio/dio.dart'; +import 'package:flutter_nominatim/flutter_nominatim.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'places_api_client.g.dart'; class PlacesApiClient { - PlacesApiClient({Dio? dio, String? apiKey}) - : _dio = - dio ?? - Dio( - BaseOptions( - baseUrl: 'https://places.googleapis.com/v1/', - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 10), - ), - ), - _apiKey = apiKey ?? Env.placesApiKey; + PlacesApiClient({Nominatim? nominatim}) : _nominatim = nominatim ?? Nominatim.instance; - final Dio _dio; - final String _apiKey; - final Map _detailsCache = {}; - - Future> autocomplete(String query, {String countryCode = 'US'}) async { + Future> autocomplete( + String query, { + String countryCode = 'US', + int? limit, + }) async { if (query.trim().isEmpty) return []; - if (_apiKey.isEmpty) { - Log.w('Places API key missing; set GOOGLE_PLACES_API_KEY via --dart-define.'); - return []; - } + final normalizedQuery = countryCode.trim().isEmpty ? query : '$query, ${countryCode.trim()}'; try { - final response = await _dio.post>( - 'places:autocomplete', - data: { - 'input': query, - 'includedRegionCodes': [countryCode], - }, - options: Options( - headers: { - 'X-Goog-Api-Key': _apiKey, - 'X-Goog-FieldMask': - 'suggestions.placePrediction.placeId,' - 'suggestions.placePrediction.text,' - 'suggestions.placePrediction.structuredFormat', - }, - ), - ); - final suggestions = response.data?['suggestions'] as List? ?? []; - return suggestions - .map((e) => PlacesPrediction.fromJson(e as Map)) - .where((p) => p.placeId.isNotEmpty) + final results = await _nominatim.search(normalizedQuery); + return (limit != null ? results.take(limit) : results) + .map(PlacesPrediction.fromPlace) .toList(); } catch (e, stackTrace) { - Log.e('Places autocomplete error', e, stackTrace); + Log.e('Nominatim search error', e, stackTrace); rethrow; } } - Future placeDetails(String placeId) async { - if (_detailsCache.containsKey(placeId)) return _detailsCache[placeId]!; - if (_apiKey.isEmpty) { - throw StateError('Places API key missing; set GOOGLE_PLACES_API_KEY via --dart-define.'); - } - try { - final response = await _dio.get>( - 'places/$placeId', - options: Options( - headers: { - 'X-Goog-Api-Key': _apiKey, - 'X-Goog-FieldMask': 'id,displayName,addressComponents,location', - }, - ), - ); - final details = PlaceDetails.fromJson(response.data ?? {}); - _detailsCache[placeId] = details; - return details; - } catch (e, stackTrace) { - Log.e('Places details error', e, stackTrace); - rethrow; - } - } + final Nominatim _nominatim; } @riverpod diff --git a/lib/core/features/location/domain/places_models.dart b/lib/core/features/location/domain/places_models.dart index 8716767..cacc96b 100644 --- a/lib/core/features/location/domain/places_models.dart +++ b/lib/core/features/location/domain/places_models.dart @@ -1,7 +1,10 @@ +import 'package:flutter_nominatim/flutter_nominatim.dart'; + class PlacesPrediction { PlacesPrediction({ required this.placeId, required this.description, + required this.details, this.mainText, this.secondaryText, }); @@ -10,39 +13,20 @@ class PlacesPrediction { final String description; final String? mainText; final String? secondaryText; - - factory PlacesPrediction.fromJson(Map json) { - final placePrediction = json['placePrediction'] as Map? ?? json; - final text = placePrediction['text'] as Map?; - final structured = placePrediction['structuredFormat'] as Map?; - final mainText = structured?['mainText'] as Map?; - final secondaryText = structured?['secondaryText'] as Map?; + final PlaceDetails details; + + factory PlacesPrediction.fromPlace(Place place) { + final description = place.displayName; + final split = description.split(','); + final mainText = split.isNotEmpty ? split.first.trim() : description; + final secondaryText = + split.length > 1 ? split.sublist(1).map((e) => e.trim()).join(', ') : null; return PlacesPrediction( - placeId: (placePrediction['placeId'] ?? placePrediction['place_id'] ?? '').toString(), - description: (text?['text'] ?? placePrediction['description'] ?? '').toString(), - mainText: (mainText?['text'] ?? '').toString().trim().isEmpty - ? null - : (mainText?['text'] ?? '').toString(), - secondaryText: (secondaryText?['text'] ?? '').toString().trim().isEmpty - ? null - : (secondaryText?['text'] ?? '').toString(), - ); - } -} - -class AddressComponent { - AddressComponent({required this.longText, required this.shortText, required this.types}); - - final String longText; - final String shortText; - final List types; - - factory AddressComponent.fromJson(Map json) { - final types = (json['types'] as List? ?? []).map((e) => e.toString()).toList(); - return AddressComponent( - longText: (json['longText'] ?? json['long_name'] ?? '').toString(), - shortText: (json['shortText'] ?? json['short_name'] ?? '').toString(), - types: types, + placeId: place.placeId, + description: description, + mainText: mainText.isEmpty ? null : mainText, + secondaryText: secondaryText?.isEmpty == true ? null : secondaryText, + details: PlaceDetails.fromPlace(place), ); } } @@ -50,30 +34,25 @@ class AddressComponent { class PlaceDetails { PlaceDetails({ required this.placeId, - required this.name, - required this.addressComponents, + required this.displayName, + required this.addressDetails, required this.latitude, required this.longitude, }); final String placeId; - final String? name; - final List addressComponents; - final double? latitude; - final double? longitude; + final String displayName; + final Map addressDetails; + final double latitude; + final double longitude; - factory PlaceDetails.fromJson(Map json) { - final components = (json['addressComponents'] as List? ?? []) - .map((e) => AddressComponent.fromJson(e as Map)) - .toList(); - final location = json['location'] as Map? ?? {}; - final displayName = json['displayName'] as Map?; + factory PlaceDetails.fromPlace(Place place) { return PlaceDetails( - placeId: (json['id'] ?? json['placeId'] ?? json['place_id'] ?? '').toString(), - name: (displayName?['text'] ?? json['name'])?.toString(), - addressComponents: components, - latitude: (location['latitude'] as num?)?.toDouble(), - longitude: (location['longitude'] as num?)?.toDouble(), + placeId: place.placeId, + displayName: place.displayName, + addressDetails: Map.from(place.addressDetails), + latitude: place.latitude, + longitude: place.longitude, ); } } diff --git a/lib/core/features/location/utils/place_to_address.dart b/lib/core/features/location/utils/place_to_address.dart index e14a52a..7dba2c9 100644 --- a/lib/core/features/location/utils/place_to_address.dart +++ b/lib/core/features/location/utils/place_to_address.dart @@ -1,3 +1,4 @@ +import 'package:anystep/core/common/utils/state_utils.dart'; import 'package:anystep/core/features/location/domain/places_models.dart'; class ParsedPlaceAddress { @@ -27,28 +28,26 @@ class ParsedPlaceAddress { } ParsedPlaceAddress placeDetailsToAddress(PlaceDetails details) { - String? componentLong(String type) { - final component = details.addressComponents.where((c) => c.types.contains(type)).toList(); - return component.isNotEmpty ? component.first.longText : null; + String? firstValue(Iterable keys) { + for (final key in keys) { + final value = details.addressDetails[key]; + if (value != null) { + final trimmed = value.toString().trim(); + if (trimmed.isNotEmpty) return trimmed; + } + } + return null; } - String? componentShort(String type) { - final component = details.addressComponents.where((c) => c.types.contains(type)).toList(); - return component.isNotEmpty ? component.first.shortText : null; - } - - final streetNumber = componentLong('street_number') ?? ''; - final route = componentLong('route') ?? ''; + final streetNumber = firstValue(['house_number', 'street_number']) ?? ''; + final route = firstValue(['road', 'pedestrian', 'footway', 'street', 'residential']) ?? ''; final street = [streetNumber, route].where((p) => p.trim().isNotEmpty).join(' ').trim(); - final streetSecondary = componentLong('subpremise'); - final city = - componentLong('locality') ?? - componentLong('postal_town') ?? - componentLong('administrative_area_level_2') ?? - ''; - final state = componentShort('administrative_area_level_1') ?? ''; - final postalCode = componentLong('postal_code') ?? ''; - final country = componentShort('country') ?? 'US'; + final streetSecondary = firstValue(['unit', 'floor', 'flat', 'apartment', 'suite']); + final city = firstValue(['city', 'town', 'village', 'hamlet', 'municipality', 'county']) ?? ''; + final rawState = firstValue(['state', 'region', 'state_district']) ?? ''; + final state = normalizeUsState(rawState) ?? rawState; + final postalCode = firstValue(['postcode']) ?? ''; + final country = (firstValue(['country_code']) ?? 'US').toUpperCase(); return ParsedPlaceAddress( street: street, @@ -58,7 +57,6 @@ ParsedPlaceAddress placeDetailsToAddress(PlaceDetails details) { postalCode: postalCode, country: country, placeId: details.placeId, - name: details.name, latitude: details.latitude, longitude: details.longitude, ); diff --git a/lib/core/features/profile/presentation/onboarding/onboarding_screen.dart b/lib/core/features/profile/presentation/onboarding/onboarding_screen.dart index 2a031e0..d406443 100644 --- a/lib/core/features/profile/presentation/onboarding/onboarding_screen.dart +++ b/lib/core/features/profile/presentation/onboarding/onboarding_screen.dart @@ -163,14 +163,13 @@ class _OnboardingScreenState extends ConsumerState { // ), // ), const SizedBox(height: AnyStepSpacing.md16), - AnyStepAddressField( + AnyStepAddressModalTile( formKey: _formKey, - postalCodeFieldName: 'zipCode', streetSecondaryLabelText: loc.streetAddress2, isUserAddress: true, - disableSearch: true, includeEventAddresses: false, includeUserAddresses: false, + addressIdValidator: FormBuilderValidators.required(), ), const SizedBox(height: AnyStepSpacing.md16), diff --git a/lib/core/features/profile/presentation/profile/profile_form.dart b/lib/core/features/profile/presentation/profile/profile_form.dart index b8fff0b..bec92b1 100644 --- a/lib/core/features/profile/presentation/profile/profile_form.dart +++ b/lib/core/features/profile/presentation/profile/profile_form.dart @@ -79,18 +79,13 @@ class _ProfileFormState extends ConsumerState { validator: FormBuilderValidators.required(), ), const SizedBox(height: AnyStepSpacing.sm8), - Align( - alignment: Alignment.centerLeft, - child: Text(loc.address, style: Theme.of(context).textTheme.titleMedium), - ), - const SizedBox(height: AnyStepSpacing.sm4), - AnyStepAddressField( + AnyStepAddressModalTile( formKey: _formKey, initialAddressId: widget.user.addressId ?? widget.user.address?.id, isUserAddress: true, - disableSearch: true, includeEventAddresses: false, - includeUserAddresses: true, + includeUserAddresses: false, + addressIdValidator: FormBuilderValidators.required(), ), const SizedBox(height: AnyStepSpacing.md24), if (state.isLoading) const AnyStepLoadingIndicator(), diff --git a/lib/core/shared_prefs/shared_prefs.dart b/lib/core/shared_prefs/shared_prefs.dart index bcd0fae..1c0df7b 100644 --- a/lib/core/shared_prefs/shared_prefs.dart +++ b/lib/core/shared_prefs/shared_prefs.dart @@ -14,6 +14,7 @@ class AppPreferences { static const String themeModeKey = 'theme_mode'; static const String localeCodeKey = 'locale_code'; static const String eventNotificationsKey = 'event_notifications'; + static const String welcomeMessageSeenKey = 'welcome_message_seen'; String? getAuthStateJson() => _prefs.getString(authStateKey); Future setAuthStateJson(String token) async => await _prefs.setString(authStateKey, token); @@ -42,6 +43,11 @@ class AppPreferences { await _prefs.setBool(eventNotificationsKey, enabled); Future clearEventNotificationsEnabled() async => await _prefs.remove(eventNotificationsKey); + + bool getWelcomeMessageSeen() => _prefs.getBool(welcomeMessageSeenKey) ?? false; + Future setWelcomeMessageSeen({bool seen = true}) async => + await _prefs.setBool(welcomeMessageSeenKey, seen); + Future clearWelcomeMessageSeen() async => await _prefs.remove(welcomeMessageSeenKey); } @Riverpod(keepAlive: true) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 11574e2..0e656fd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -11,6 +11,10 @@ "name": {"type": "String", "example": "Alice"} } }, + "welcomeMessageTitle": "Welcome", + "@welcomeMessageTitle": {"description": "Title for the remote-config welcome message modal"}, + "welcomeMessageDismiss": "Got it", + "@welcomeMessageDismiss": {"description": "Dismiss button label for the welcome message modal"}, "itemCount": "{count, plural, =0 {No items} one {1 item} other {{count} items}}", "@itemCount": { "description": "Pluralized count of generic items.", @@ -394,6 +398,8 @@ "tapToSign": "Tap to sign", "@tapToSign": {"description": "Button label to sign agreement"} , + "optional": "optional", + "@optional": {"description": "Text shown when field is optional"}, "validatorRequired": "This field is required", "@validatorRequired": {"description": "Error shown when a required field is empty"}, "validatorEmail": "Please enter a valid email address", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 93445bc..fb2c54f 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -11,6 +11,10 @@ "name": {"type": "String", "example": "Alicia"} } }, + "welcomeMessageTitle": "Bienvenido", + "@welcomeMessageTitle": {"description": "Título del modal de mensaje de bienvenida remoto"}, + "welcomeMessageDismiss": "Entendido", + "@welcomeMessageDismiss": {"description": "Etiqueta del botón para cerrar el mensaje de bienvenida"}, "itemCount": "{count, plural, =0 {No hay elementos} one {1 elemento} other {{count} elementos}}", "@itemCount": { "description": "Conteo pluralizado de elementos genéricos.", @@ -390,6 +394,8 @@ "getStarted": "Comenzar", "@getStarted": {"description": "Etiqueta del botón final del onboarding"} , + "optional": "opcional", + "@optional": {"description": "Cuando un campo es opcional"}, "validatorRequired": "Este campo es obligatorio", "@validatorRequired": {"description": "Error cuando un campo requerido está vacío"}, "validatorEmail": "Ingresa un correo electrónico válido", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 194cbb3..908702a 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -110,6 +110,18 @@ abstract class AppLocalizations { /// **'Welcome, {name}!'** String welcomeUser(String name); + /// Title for the remote-config welcome message modal + /// + /// In en, this message translates to: + /// **'Welcome'** + String get welcomeMessageTitle; + + /// Dismiss button label for the welcome message modal + /// + /// In en, this message translates to: + /// **'Got it'** + String get welcomeMessageDismiss; + /// Pluralized count of generic items. /// /// In en, this message translates to: @@ -1124,6 +1136,12 @@ abstract class AppLocalizations { /// **'Tap to sign'** String get tapToSign; + /// Text shown when field is optional + /// + /// In en, this message translates to: + /// **'optional'** + String get optional; + /// Error shown when a required field is empty /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 6dd46ef..aa0d2e3 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -16,6 +16,12 @@ class AppLocalizationsEn extends AppLocalizations { return 'Welcome, $name!'; } + @override + String get welcomeMessageTitle => 'Welcome'; + + @override + String get welcomeMessageDismiss => 'Got it'; + @override String itemCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -566,6 +572,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tapToSign => 'Tap to sign'; + @override + String get optional => 'optional'; + @override String get validatorRequired => 'This field is required'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 8f00520..2805f31 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -16,6 +16,12 @@ class AppLocalizationsEs extends AppLocalizations { return '¡Bienvenido, $name!'; } + @override + String get welcomeMessageTitle => 'Bienvenido'; + + @override + String get welcomeMessageDismiss => 'Entendido'; + @override String itemCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -571,6 +577,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get tapToSign => 'Toca para firmar'; + @override + String get optional => 'opcional'; + @override String get validatorRequired => 'Este campo es obligatorio'; diff --git a/pubspec.lock b/pubspec.lock index f4095ae..64c9695 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -499,6 +499,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_nominatim: + dependency: "direct main" + description: + name: flutter_nominatim + sha256: "37f52d8ead24b97a7a6fb46fac743ca79109b50103bdf991e4af76005ec46bc2" + url: "https://pub.dev" + source: hosted + version: "0.0.1+2" flutter_plugin_android_lifecycle: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2c220e1..8f1ce79 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: table_calendar: ^3.1.2 font_awesome_flutter: ^10.7.0 form_builder_validators: ^11.1.2 + flutter_nominatim: ^0.0.1+2 freezed_annotation: ^3.0.0 go_router: ^15.1.2 image: ^4.5.4 From 394255e2992a4aadfc30f4421d808b2e8a662a68 Mon Sep 17 00:00:00 2001 From: alexdivadi Date: Thu, 19 Feb 2026 23:22:07 -0600 Subject: [PATCH 3/5] fix: address selector and notifications --- ios/Podfile.lock | 34 ++ lib/core/common/utils/geo.dart | 9 +- .../inputs/any_step_address_field.dart | 107 ++++--- .../location/data/places_api_client.dart | 51 ++- .../location/domain/places_models.dart | 86 +++-- .../data/event_notifications_controller.dart | 32 +- .../event_notifications_controller.g.dart | 2 +- .../data/notification_repository.dart | 80 +---- .../data/notification_repository.g.dart | 4 +- .../notifications/domain/notification.dart | 22 ++ .../domain/notification.freezed.dart | 298 ++++++++++++++++++ .../notifications/domain/notification.g.dart | 31 ++ .../event_notifications_tile.dart | 5 +- .../features/profile/domain/user_model.dart | 3 + .../profile/domain/user_model.freezed.dart | 43 +-- .../features/profile/domain/user_model.g.dart | 3 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 104 +++++- pubspec.yaml | 3 +- supabase/functions/push/index.ts | 175 +++++----- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 22 files changed, 827 insertions(+), 271 deletions(-) create mode 100644 lib/core/features/notifications/domain/notification.dart create mode 100644 lib/core/features/notifications/domain/notification.freezed.dart create mode 100644 lib/core/features/notifications/domain/notification.g.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 20dea58..6d0c378 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,5 @@ PODS: + - Alamofire (5.11.1) - app_links (6.4.1): - Flutter - Firebase/CoreOnly (12.4.0): @@ -53,6 +54,12 @@ PODS: - FirebaseRemoteConfigInterop (12.4.0) - FirebaseSharedSwift (12.4.0) - Flutter (1.0.0) + - flutter_osm_plugin (0.0.1): + - Alamofire + - Flutter + - OSMFlutterFramework + - Polyline + - Yams - GoogleDataTransport (10.1.0): - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) @@ -87,9 +94,15 @@ PODS: - nanopb/encode (= 3.30910.0) - nanopb/decode (3.30910.0) - nanopb/encode (3.30910.0) + - OSMFlutterFramework (0.8.4) + - package_info_plus (0.4.5): + - Flutter - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - permission_handler_apple (9.3.0): + - Flutter + - Polyline (5.1.0) - PostHog (3.36.0) - posthog_flutter (0.0.1): - Flutter @@ -103,6 +116,7 @@ PODS: - FlutterMacOS - url_launcher_ios (0.0.1): - Flutter + - Yams (5.0.6) DEPENDENCIES: - app_links (from `.symlinks/plugins/app_links/ios`) @@ -110,8 +124,11 @@ DEPENDENCIES: - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - firebase_remote_config (from `.symlinks/plugins/firebase_remote_config/ios`) - Flutter (from `Flutter`) + - flutter_osm_plugin (from `.symlinks/plugins/flutter_osm_plugin/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - posthog_flutter (from `.symlinks/plugins/posthog_flutter/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) @@ -119,6 +136,7 @@ DEPENDENCIES: SPEC REPOS: trunk: + - Alamofire - Firebase - FirebaseABTesting - FirebaseCore @@ -131,8 +149,11 @@ SPEC REPOS: - GoogleDataTransport - GoogleUtilities - nanopb + - OSMFlutterFramework + - Polyline - PostHog - PromisesObjC + - Yams EXTERNAL SOURCES: app_links: @@ -145,10 +166,16 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/firebase_remote_config/ios" Flutter: :path: Flutter + flutter_osm_plugin: + :path: ".symlinks/plugins/flutter_osm_plugin/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" posthog_flutter: :path: ".symlinks/plugins/posthog_flutter/ios" share_plus: @@ -159,6 +186,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: + Alamofire: eec6cd8f73b242b59e34153a606a909eb9864b14 app_links: 585674be3c6661708e6cd794ab4f39fb9d8356f9 Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e firebase_core: e6b8bb503b7d1d9856e698d4f193f7b414e6bf1f @@ -173,17 +201,23 @@ SPEC CHECKSUMS: FirebaseRemoteConfigInterop: 1e31ec72b89c9924367c59bfb5ec9ab60d1d6766 FirebaseSharedSwift: 93426a1de92f19e1199fac5295a4f8df16458daa Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_osm_plugin: 60be722901333b6107275c9c51ffb79167b6d99d GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + OSMFlutterFramework: 2f1260ac2854d3398b92403f2d5e012b4ca2c620 + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + Polyline: 2a1f29f87f8d9b7de868940f4f76deb8c678a5b1 PostHog: 8e04df01d59971f1fd85d0273e18ba61076fef72 posthog_flutter: c7888a7df4a4eb0a6473c50da2e12520c33408c4 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa + Yams: e10dae147f517ed57ecae37c5e8681bdf8fcab65 PODFILE CHECKSUM: 53a6aebc29ccee84c41f92f409fc20cd4ca011f1 diff --git a/lib/core/common/utils/geo.dart b/lib/core/common/utils/geo.dart index 2ffd87c..ee0a13b 100644 --- a/lib/core/common/utils/geo.dart +++ b/lib/core/common/utils/geo.dart @@ -5,6 +5,11 @@ final geoHasher = GeoHasher(); /// Utility function to calculate geohash from latitude and longitude. String? calculateGeohash(double? latitude, double? longitude, {int precision = 9}) { if (latitude == null || longitude == null) return null; - final geohash = geoHasher.encode(latitude, longitude, precision: precision); - return geohash; + // dart_geohash expects (longitude, latitude) + try { + final geohash = geoHasher.encode(longitude, latitude, precision: precision); + return geohash; + } catch (_) { + return null; + } } diff --git a/lib/core/common/widgets/inputs/any_step_address_field.dart b/lib/core/common/widgets/inputs/any_step_address_field.dart index ee721c0..f9cdeba 100644 --- a/lib/core/common/widgets/inputs/any_step_address_field.dart +++ b/lib/core/common/widgets/inputs/any_step_address_field.dart @@ -98,6 +98,7 @@ class _AnyStepAddressFieldState extends ConsumerState { bool _isSearchDisabled = false; bool _isApplyingSelection = false; bool _isSelectingFromList = false; + bool _pointerDownOnResults = false; bool _searchActive = false; bool _isSaving = false; List _dbResults = []; @@ -144,9 +145,11 @@ class _AnyStepAddressFieldState extends ConsumerState { void _handleFocusChange() { if (!_streetFocusNode.hasFocus) { _searchActive = false; - if (_isSelectingFromList) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || _isSelectingFromList) return; + _debounce?.cancel(); + _isLoading = false; + if (_isSelectingFromList || _pointerDownOnResults) return; + Future.delayed(const Duration(milliseconds: 50), () { + if (!mounted || _isSelectingFromList || _pointerDownOnResults) return; setState(() { _dbResults = []; _predictions = []; @@ -186,6 +189,7 @@ class _AnyStepAddressFieldState extends ConsumerState { final query = value?.trim() ?? ''; if (query.isEmpty) { _searchActive = false; + _isLoading = false; setState(() { _dbResults = []; _predictions = []; @@ -242,6 +246,8 @@ class _AnyStepAddressFieldState extends ConsumerState { final form = widget.formKey.currentState; if (form == null) return; _isApplyingSelection = true; + _debounce?.cancel(); + _isLoading = false; if (widget.showNameField) { form.fields[widget.nameFieldName]?.didChange(address.name); } @@ -273,6 +279,7 @@ class _AnyStepAddressFieldState extends ConsumerState { setState(() { _dbResults = []; _predictions = []; + _error = null; }); } @@ -285,15 +292,23 @@ class _AnyStepAddressFieldState extends ConsumerState { final parsed = placeDetailsToAddress(prediction.details); final form = widget.formKey.currentState; if (form == null) return; + _isApplyingSelection = true; + _debounce?.cancel(); + _addressId = null; + form.fields[widget.addressIdFieldName]?.didChange(null); + if (widget.showNameField) { + final nameValue = parsed.name ?? prediction.mainText ?? prediction.description; + form.fields[widget.nameFieldName]?.didChange(nameValue); + } form.fields['street']?.didChange(parsed.street); form.fields['streetSecondary']?.didChange(parsed.streetSecondary); form.fields['city']?.didChange(parsed.city); form.fields['state']?.didChange(parsed.state); form.fields[widget.postalCodeFieldName]?.didChange(parsed.postalCode); _placeId = parsed.placeId; + _placeName = parsed.name; _latitude = parsed.latitude; _longitude = parsed.longitude; - _isApplyingSelection = true; _streetController.text = parsed.street.isNotEmpty ? parsed.street : prediction.description; _streetController.selection = TextSelection.fromPosition( TextPosition(offset: _streetController.text.length), @@ -306,6 +321,7 @@ class _AnyStepAddressFieldState extends ConsumerState { _dbResults = []; _predictions = []; _isLoading = false; + _error = null; }); } catch (e, stackTrace) { Log.e('Places selection error', e, stackTrace); @@ -321,6 +337,9 @@ class _AnyStepAddressFieldState extends ConsumerState { final form = widget.formKey.currentState; if (form == null || widget.initialAddressId == null) return; try { + _isApplyingSelection = true; + _searchActive = false; + _debounce?.cancel(); final repo = ref.read(addressRepositoryProvider); final address = await repo.get(documentId: widget.initialAddressId.toString()); _placeId = address.placeId; @@ -337,8 +356,13 @@ class _AnyStepAddressFieldState extends ConsumerState { form.fields[widget.postalCodeFieldName]?.didChange(address.postalCode); form.fields[widget.addressIdFieldName]?.didChange(address.id); _streetController.text = address.street; + _streetController.selection = TextSelection.fromPosition( + TextPosition(offset: _streetController.text.length), + ); } catch (e, stackTrace) { Log.e('Error loading address', e, stackTrace); + } finally { + _isApplyingSelection = false; } } @@ -418,7 +442,8 @@ class _AnyStepAddressFieldState extends ConsumerState { padding: const EdgeInsets.only(bottom: AnyStepSpacing.sm4), child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), ), - if (_dbResults.isEmpty && + if (_searchActive && + _dbResults.isEmpty && _predictions.isEmpty && !_isLoading && _streetController.text.trim().isNotEmpty) @@ -428,41 +453,51 @@ class _AnyStepAddressFieldState extends ConsumerState { ), if (_searchActive && (_dbResults.isNotEmpty || (!_isSearchDisabled && _predictions.isNotEmpty))) - Card( - margin: const EdgeInsets.only(bottom: AnyStepSpacing.sm8), - child: ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: _dbResults.length + _predictions.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (context, index) { - if (index < _dbResults.length) { - final address = _dbResults[index]; - final title = address.name ?? address.formattedAddress; - final subtitle = address.name != null ? address.formattedAddress : null; + Listener( + onPointerDown: (_) { + _pointerDownOnResults = true; + _isSelectingFromList = true; + }, + onPointerUp: (_) { + _pointerDownOnResults = false; + _isSelectingFromList = false; + }, + onPointerCancel: (_) { + _pointerDownOnResults = false; + _isSelectingFromList = false; + }, + child: Card( + margin: const EdgeInsets.only(bottom: AnyStepSpacing.sm8), + child: ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _dbResults.length + _predictions.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + if (index < _dbResults.length) { + final address = _dbResults[index]; + final title = address.name ?? address.formattedAddress; + final subtitle = address.name != null ? address.formattedAddress : null; + return InkWell( + onTap: () => _selectAddress(address), + child: ListTile( + title: Text(title), + subtitle: subtitle != null ? Text(subtitle) : null, + ), + ); + } + final prediction = _predictions[index - _dbResults.length]; return InkWell( - onTapDown: (_) => _isSelectingFromList = true, - onTapCancel: () => _isSelectingFromList = false, - onTap: () => _selectAddress(address), + onTap: () => _selectPrediction(prediction), child: ListTile( - title: Text(title), - subtitle: subtitle != null ? Text(subtitle) : null, + title: Text(prediction.mainText ?? prediction.description), + subtitle: prediction.secondaryText != null + ? Text(prediction.secondaryText!) + : null, ), ); - } - final prediction = _predictions[index - _dbResults.length]; - return InkWell( - onTapDown: (_) => _isSelectingFromList = true, - onTapCancel: () => _isSelectingFromList = false, - onTap: () => _selectPrediction(prediction), - child: ListTile( - title: Text(prediction.mainText ?? prediction.description), - subtitle: prediction.secondaryText != null - ? Text(prediction.secondaryText!) - : null, - ), - ); - }, + }, + ), ), ), FormBuilderField( diff --git a/lib/core/features/location/data/places_api_client.dart b/lib/core/features/location/data/places_api_client.dart index dbb91ad..ae53e5f 100644 --- a/lib/core/features/location/data/places_api_client.dart +++ b/lib/core/features/location/data/places_api_client.dart @@ -1,12 +1,15 @@ import 'package:anystep/core/common/utils/log_utils.dart'; import 'package:anystep/core/features/location/domain/places_models.dart'; -import 'package:flutter_nominatim/flutter_nominatim.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_osm_interface/flutter_osm_interface.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'places_api_client.g.dart'; class PlacesApiClient { - PlacesApiClient({Nominatim? nominatim}) : _nominatim = nominatim ?? Nominatim.instance; + static const _photonBaseUrl = 'https://photon.komoot.io/api/'; + + PlacesApiClient({Dio? dio}) : _dio = dio ?? Dio(); Future> autocomplete( String query, { @@ -14,19 +17,49 @@ class PlacesApiClient { int? limit, }) async { if (query.trim().isEmpty) return []; - final normalizedQuery = countryCode.trim().isEmpty ? query : '$query, ${countryCode.trim()}'; + final normalizedQuery = countryCode.trim().isEmpty + ? query.trim() + : '${query.trim()}, ${countryCode.trim()}'; try { - final results = await _nominatim.search(normalizedQuery); - return (limit != null ? results.take(limit) : results) - .map(PlacesPrediction.fromPlace) - .toList(); + final locale = _mapLocale(countryCode); + final response = await _dio.get( + _photonBaseUrl, + queryParameters: { + 'q': normalizedQuery, + 'limit': limit ?? 10, + if (locale.isNotEmpty) 'lang': locale, + }, + options: Options( + headers: const { + // Photon demo server expects a descriptive User-Agent. + // Update with a contact URL/email if you have one. + 'User-Agent': 'AnyStepApp/1.0', + }, + ), + ); + final data = response.data as Map; + final features = (data['features'] as List?) ?? const []; + final results = features.map((d) => SearchInfo.fromPhotonAPI(d)).toList(); + return results.map(PlacesPrediction.fromSearchInfo).toList(); } catch (e, stackTrace) { - Log.e('Nominatim search error', e, stackTrace); + Log.e('Address suggestion error', e, stackTrace); rethrow; } } - final Nominatim _nominatim; + String _mapLocale(String countryCode) { + final normalized = countryCode.trim().toLowerCase(); + switch (normalized) { + case 'de': + case 'en': + case 'fr': + return normalized; + default: + return 'en'; + } + } + + final Dio _dio; } @riverpod diff --git a/lib/core/features/location/domain/places_models.dart b/lib/core/features/location/domain/places_models.dart index cacc96b..2a79232 100644 --- a/lib/core/features/location/domain/places_models.dart +++ b/lib/core/features/location/domain/places_models.dart @@ -1,4 +1,4 @@ -import 'package:flutter_nominatim/flutter_nominatim.dart'; +import 'package:flutter_osm_interface/flutter_osm_interface.dart'; class PlacesPrediction { PlacesPrediction({ @@ -15,20 +15,59 @@ class PlacesPrediction { final String? secondaryText; final PlaceDetails details; - factory PlacesPrediction.fromPlace(Place place) { - final description = place.displayName; - final split = description.split(','); - final mainText = split.isNotEmpty ? split.first.trim() : description; - final secondaryText = - split.length > 1 ? split.sublist(1).map((e) => e.trim()).join(', ') : null; + factory PlacesPrediction.fromSearchInfo(SearchInfo info) { + final address = info.address; + final description = address?.toString(separator: ", ") ?? ''; + final mainText = _formatMainText(address, description); + final secondaryText = _formatSecondaryText(address, description, mainText); return PlacesPrediction( - placeId: place.placeId, - description: description, - mainText: mainText.isEmpty ? null : mainText, - secondaryText: secondaryText?.isEmpty == true ? null : secondaryText, - details: PlaceDetails.fromPlace(place), + placeId: _buildPlaceId(info, description), + description: description.isNotEmpty ? description : mainText, + mainText: mainText, + secondaryText: secondaryText, + details: PlaceDetails.fromSearchInfo(info), ); } + + static String _formatMainText(Address? address, String fallback) { + if (address == null) return fallback.isNotEmpty ? fallback : ''; + final name = address.name?.trim(); + if (name != null && name.isNotEmpty) return name; + final street = address.street?.trim(); + final house = address.housenumber?.trim(); + if (street != null && street.isNotEmpty && house != null && house.isNotEmpty) { + return '$street $house'; + } + if (street != null && street.isNotEmpty) return street; + return fallback; + } + + static String? _formatSecondaryText(Address? address, String description, String? mainText) { + if (address == null) return null; + final parts = []; + final city = address.city?.trim(); + final state = address.state?.trim(); + final postcode = address.postcode?.trim(); + final country = address.country?.trim(); + if (city != null && city.isNotEmpty) parts.add(city); + if (state != null && state.isNotEmpty) parts.add(state); + if (postcode != null && postcode.isNotEmpty) parts.add(postcode); + if (country != null && country.isNotEmpty) parts.add(country); + final secondary = parts.join(', '); + if (secondary.isEmpty) return null; + if (mainText != null && description.startsWith(mainText)) { + return secondary; + } + return secondary; + } + + static String _buildPlaceId(SearchInfo info, String description) { + final point = info.point; + final lat = point?.latitude.toStringAsFixed(6) ?? '0'; + final lng = point?.longitude.toStringAsFixed(6) ?? '0'; + final base = description.isNotEmpty ? description : 'unknown'; + return '$lat,$lng:$base'; + } } class PlaceDetails { @@ -46,13 +85,24 @@ class PlaceDetails { final double latitude; final double longitude; - factory PlaceDetails.fromPlace(Place place) { + factory PlaceDetails.fromSearchInfo(SearchInfo info) { + final address = info.address; + final point = info.point; + final addressDetails = { + if (address?.street != null) 'road': address?.street, + if (address?.housenumber != null) 'house_number': address?.housenumber, + if (address?.postcode != null) 'postcode': address?.postcode, + if (address?.city != null) 'city': address?.city, + if (address?.state != null) 'state': address?.state, + if (address?.country != null) 'country': address?.country, + if (address?.name != null) 'name': address?.name, + }; return PlaceDetails( - placeId: place.placeId, - displayName: place.displayName, - addressDetails: Map.from(place.addressDetails), - latitude: place.latitude, - longitude: place.longitude, + placeId: PlacesPrediction._buildPlaceId(info, address?.toString(separator: ", ") ?? ''), + displayName: address?.toString(separator: ", ") ?? '', + addressDetails: addressDetails, + latitude: point?.latitude ?? 0, + longitude: point?.longitude ?? 0, ); } } diff --git a/lib/core/features/notifications/data/event_notifications_controller.dart b/lib/core/features/notifications/data/event_notifications_controller.dart index cf89c24..304a6e8 100644 --- a/lib/core/features/notifications/data/event_notifications_controller.dart +++ b/lib/core/features/notifications/data/event_notifications_controller.dart @@ -1,5 +1,5 @@ -import 'package:anystep/core/features/notifications/data/notification_repository.dart'; -import 'package:anystep/core/shared_prefs/shared_prefs.dart'; +import 'package:anystep/core/features/profile/data/current_user.dart'; +import 'package:anystep/core/features/profile/data/user_repository.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'event_notifications_controller.g.dart'; @@ -8,25 +8,25 @@ part 'event_notifications_controller.g.dart'; class EventNotificationsController extends _$EventNotificationsController { @override Future build() async { - final prefs = await ref.watch(appPreferencesProvider.future); - return prefs.getEventNotificationsEnabled() ?? true; + final user = await ref.watch(currentUserStreamProvider.future); + return user?.newEventNotificationsEnabled ?? true; } - Future setEnabled( - bool enabled, { - bool requestPermission = true, - }) async { + Future setEnabled(bool enabled) async { state = const AsyncLoading(); - await ref.read(notificationRepositoryProvider).setEventNotificationsEnabled( - enabled, - requestPermission: requestPermission, - ); - state = AsyncData(enabled); + state = await AsyncValue.guard(() async { + final user = await ref.read(currentUserStreamProvider.future); + if (user == null) { + throw StateError('No authenticated user available for notification settings.'); + } + final updated = user.copyWith(newEventNotificationsEnabled: enabled); + await ref.read(userRepositoryProvider).createOrUpdate(obj: updated, documentId: user.id); + ref.invalidate(currentUserStreamProvider); + return enabled; + }); } Future enableOnSignup() async { - final prefs = await ref.read(appPreferencesProvider.future); - if (prefs.getEventNotificationsEnabled() != null) return; - await setEnabled(true, requestPermission: true); + await setEnabled(true); } } diff --git a/lib/core/features/notifications/data/event_notifications_controller.g.dart b/lib/core/features/notifications/data/event_notifications_controller.g.dart index da0721a..5c3fc03 100644 --- a/lib/core/features/notifications/data/event_notifications_controller.g.dart +++ b/lib/core/features/notifications/data/event_notifications_controller.g.dart @@ -35,7 +35,7 @@ final class EventNotificationsControllerProvider } String _$eventNotificationsControllerHash() => - r'9d861a1b60897a8b361e4e50e56c638b1f68e86a'; + r'c6db4a7984c1bee29080daaf3a9547b067d63e6d'; abstract class _$EventNotificationsController extends $AsyncNotifier { FutureOr build(); diff --git a/lib/core/features/notifications/data/notification_repository.dart b/lib/core/features/notifications/data/notification_repository.dart index 640b9c9..c0f01e0 100644 --- a/lib/core/features/notifications/data/notification_repository.dart +++ b/lib/core/features/notifications/data/notification_repository.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:anystep/core/common/utils/log_utils.dart'; import 'package:anystep/core/config/router/router.dart'; -import 'package:anystep/core/shared_prefs/shared_prefs.dart'; import 'package:anystep/database/client.dart'; import 'package:anystep/env/env.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; @@ -14,22 +13,18 @@ import 'package:flutter/foundation.dart'; part 'notification_repository.g.dart'; -const String _eventTopic = 'event_notifications'; const String _eventIdKey = 'event_id'; String _eventDetailPath(int id) => '/events/$id'; class NotificationRepository { NotificationRepository({ - required AppPreferences prefs, required FirebaseMessaging messaging, required GoRouter router, required SupabaseClient supabase, - }) : _prefs = prefs, - _messaging = messaging, + }) : _messaging = messaging, _router = router, _supabase = supabase; - final AppPreferences _prefs; final FirebaseMessaging _messaging; final GoRouter _router; final SupabaseClient _supabase; @@ -56,34 +51,7 @@ class NotificationRepository { _handleMessageOpened(initialMessage); } - await syncSubscription(); - } - - Future syncSubscription() async { - final enabledPref = _prefs.getEventNotificationsEnabled(); - final enabled = enabledPref ?? true; - if (enabledPref == null) { - await _prefs.setEventNotificationsEnabled(true); - } - - if (enabled) { - await _subscribeIfAuthorized(requestPermission: false); - } else { - await _unsubscribeFromTopic(); - } - } - - Future setEventNotificationsEnabled( - bool enabled, { - bool requestPermission = true, - }) async { - await _prefs.setEventNotificationsEnabled(enabled); - - if (enabled) { - await _subscribeIfAuthorized(requestPermission: requestPermission); - } else { - await _unsubscribeFromTopic(); - } + await _syncToken(); } Future dispose() async { @@ -95,37 +63,8 @@ class NotificationRepository { _messageSubscription = null; } - Future _subscribeIfAuthorized({required bool requestPermission}) async { - if (requestPermission) { - final permission = await _messaging.requestPermission(); - if (permission.authorizationStatus == AuthorizationStatus.denied) { - Log.w('Event notifications permission denied.'); - return; - } - } - - final settings = await _messaging.getNotificationSettings(); - if (settings.authorizationStatus == AuthorizationStatus.denied) { - Log.w('Event notifications authorization denied.'); - return; - } - + Future syncToken() async { await _syncToken(); - - if (!kIsWeb) { - await _messaging.subscribeToTopic(_eventTopic); - Log.i('Subscribed to $_eventTopic'); - } else { - Log.i('Web platform: skipping topic subscribe; using direct FCM tokens.'); - } - } - - Future _unsubscribeFromTopic() async { - if (!kIsWeb) { - await _messaging.unsubscribeFromTopic(_eventTopic); - Log.i('Unsubscribed from $_eventTopic'); - } - await _clearToken(); } void _handleMessageOpened(RemoteMessage message) { @@ -201,24 +140,12 @@ class NotificationRepository { } } - Future _clearToken() async { - final user = _supabase.auth.currentUser; - if (user == null) return; - try { - await _supabase.from('users').update({'fcm_token': null}).eq('id', user.id); - Log.i('Cleared FCM token for user ${user.id}'); - } catch (e, st) { - Log.e('Failed to clear FCM token', e, st); - } - } } @Riverpod(keepAlive: true) NotificationRepository notificationRepository(Ref ref) { - final prefs = ref.watch(appPreferencesProvider).requireValue; final router = ref.watch(routerProvider); final repo = NotificationRepository( - prefs: prefs, messaging: FirebaseMessaging.instance, router: router, supabase: ref.watch(clientProvider), @@ -229,7 +156,6 @@ NotificationRepository notificationRepository(Ref ref) { @Riverpod(keepAlive: true) Future notificationStartup(Ref ref) async { - await ref.watch(appPreferencesProvider.future); final repo = ref.watch(notificationRepositoryProvider); await repo.init(); } diff --git a/lib/core/features/notifications/data/notification_repository.g.dart b/lib/core/features/notifications/data/notification_repository.g.dart index 5acaf5d..88d0bc2 100644 --- a/lib/core/features/notifications/data/notification_repository.g.dart +++ b/lib/core/features/notifications/data/notification_repository.g.dart @@ -55,7 +55,7 @@ final class NotificationRepositoryProvider } String _$notificationRepositoryHash() => - r'a7a3027713c4769dbb3ffc2d88b7ad0bb9e655fe'; + r'f8530528178e0c546c007cd5594b7257fdc4045c'; @ProviderFor(notificationStartup) const notificationStartupProvider = NotificationStartupProvider._(); @@ -89,4 +89,4 @@ final class NotificationStartupProvider } String _$notificationStartupHash() => - r'c0192662f449dee0c38e10f37fc320da29975f59'; + r'fa3d52d83642ceb50a11dcd183c84445e7369ef4'; diff --git a/lib/core/features/notifications/domain/notification.dart b/lib/core/features/notifications/domain/notification.dart new file mode 100644 index 0000000..015c83e --- /dev/null +++ b/lib/core/features/notifications/domain/notification.dart @@ -0,0 +1,22 @@ +// ignore_for_file: invalid_annotation_target +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'notification.freezed.dart'; +part 'notification.g.dart'; + +@freezed +abstract class NotificationModel with _$NotificationModel { + const factory NotificationModel({ + @JsonKey(includeIfNull: false) String? id, + @JsonKey(name: "user_id") String? userId, + String? title, + required String body, + String? image, + String? group, + @JsonKey(name: "created_at") required DateTime createdAt, + @JsonKey(name: "read") @Default(false) bool isRead, + }) = _NotificationModel; + + factory NotificationModel.fromJson(Map json) => + _$NotificationModelFromJson(json); +} diff --git a/lib/core/features/notifications/domain/notification.freezed.dart b/lib/core/features/notifications/domain/notification.freezed.dart new file mode 100644 index 0000000..3099306 --- /dev/null +++ b/lib/core/features/notifications/domain/notification.freezed.dart @@ -0,0 +1,298 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'notification.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$NotificationModel { + +@JsonKey(includeIfNull: false) String? get id;@JsonKey(name: "user_id") String? get userId; String? get title; String get body; String? get image; String? get group;@JsonKey(name: "created_at") DateTime get createdAt;@JsonKey(name: "read") bool get isRead; +/// Create a copy of NotificationModel +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$NotificationModelCopyWith get copyWith => _$NotificationModelCopyWithImpl(this as NotificationModel, _$identity); + + /// Serializes this NotificationModel to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is NotificationModel&&(identical(other.id, id) || other.id == id)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.title, title) || other.title == title)&&(identical(other.body, body) || other.body == body)&&(identical(other.image, image) || other.image == image)&&(identical(other.group, group) || other.group == group)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isRead, isRead) || other.isRead == isRead)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,userId,title,body,image,group,createdAt,isRead); + +@override +String toString() { + return 'NotificationModel(id: $id, userId: $userId, title: $title, body: $body, image: $image, group: $group, createdAt: $createdAt, isRead: $isRead)'; +} + + +} + +/// @nodoc +abstract mixin class $NotificationModelCopyWith<$Res> { + factory $NotificationModelCopyWith(NotificationModel value, $Res Function(NotificationModel) _then) = _$NotificationModelCopyWithImpl; +@useResult +$Res call({ +@JsonKey(includeIfNull: false) String? id,@JsonKey(name: "user_id") String? userId, String? title, String body, String? image, String? group,@JsonKey(name: "created_at") DateTime createdAt,@JsonKey(name: "read") bool isRead +}); + + + + +} +/// @nodoc +class _$NotificationModelCopyWithImpl<$Res> + implements $NotificationModelCopyWith<$Res> { + _$NotificationModelCopyWithImpl(this._self, this._then); + + final NotificationModel _self; + final $Res Function(NotificationModel) _then; + +/// Create a copy of NotificationModel +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? userId = freezed,Object? title = freezed,Object? body = null,Object? image = freezed,Object? group = freezed,Object? createdAt = null,Object? isRead = null,}) { + return _then(_self.copyWith( +id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String?,userId: freezed == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable +as String?,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String?,body: null == body ? _self.body : body // ignore: cast_nullable_to_non_nullable +as String,image: freezed == image ? _self.image : image // ignore: cast_nullable_to_non_nullable +as String?,group: freezed == group ? _self.group : group // ignore: cast_nullable_to_non_nullable +as String?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,isRead: null == isRead ? _self.isRead : isRead // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [NotificationModel]. +extension NotificationModelPatterns on NotificationModel { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _NotificationModel value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _NotificationModel() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _NotificationModel value) $default,){ +final _that = this; +switch (_that) { +case _NotificationModel(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _NotificationModel value)? $default,){ +final _that = this; +switch (_that) { +case _NotificationModel() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(includeIfNull: false) String? id, @JsonKey(name: "user_id") String? userId, String? title, String body, String? image, String? group, @JsonKey(name: "created_at") DateTime createdAt, @JsonKey(name: "read") bool isRead)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _NotificationModel() when $default != null: +return $default(_that.id,_that.userId,_that.title,_that.body,_that.image,_that.group,_that.createdAt,_that.isRead);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(includeIfNull: false) String? id, @JsonKey(name: "user_id") String? userId, String? title, String body, String? image, String? group, @JsonKey(name: "created_at") DateTime createdAt, @JsonKey(name: "read") bool isRead) $default,) {final _that = this; +switch (_that) { +case _NotificationModel(): +return $default(_that.id,_that.userId,_that.title,_that.body,_that.image,_that.group,_that.createdAt,_that.isRead);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(includeIfNull: false) String? id, @JsonKey(name: "user_id") String? userId, String? title, String body, String? image, String? group, @JsonKey(name: "created_at") DateTime createdAt, @JsonKey(name: "read") bool isRead)? $default,) {final _that = this; +switch (_that) { +case _NotificationModel() when $default != null: +return $default(_that.id,_that.userId,_that.title,_that.body,_that.image,_that.group,_that.createdAt,_that.isRead);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _NotificationModel implements NotificationModel { + const _NotificationModel({@JsonKey(includeIfNull: false) this.id, @JsonKey(name: "user_id") this.userId, this.title, required this.body, this.image, this.group, @JsonKey(name: "created_at") required this.createdAt, @JsonKey(name: "read") this.isRead = false}); + factory _NotificationModel.fromJson(Map json) => _$NotificationModelFromJson(json); + +@override@JsonKey(includeIfNull: false) final String? id; +@override@JsonKey(name: "user_id") final String? userId; +@override final String? title; +@override final String body; +@override final String? image; +@override final String? group; +@override@JsonKey(name: "created_at") final DateTime createdAt; +@override@JsonKey(name: "read") final bool isRead; + +/// Create a copy of NotificationModel +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$NotificationModelCopyWith<_NotificationModel> get copyWith => __$NotificationModelCopyWithImpl<_NotificationModel>(this, _$identity); + +@override +Map toJson() { + return _$NotificationModelToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _NotificationModel&&(identical(other.id, id) || other.id == id)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.title, title) || other.title == title)&&(identical(other.body, body) || other.body == body)&&(identical(other.image, image) || other.image == image)&&(identical(other.group, group) || other.group == group)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isRead, isRead) || other.isRead == isRead)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,userId,title,body,image,group,createdAt,isRead); + +@override +String toString() { + return 'NotificationModel(id: $id, userId: $userId, title: $title, body: $body, image: $image, group: $group, createdAt: $createdAt, isRead: $isRead)'; +} + + +} + +/// @nodoc +abstract mixin class _$NotificationModelCopyWith<$Res> implements $NotificationModelCopyWith<$Res> { + factory _$NotificationModelCopyWith(_NotificationModel value, $Res Function(_NotificationModel) _then) = __$NotificationModelCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(includeIfNull: false) String? id,@JsonKey(name: "user_id") String? userId, String? title, String body, String? image, String? group,@JsonKey(name: "created_at") DateTime createdAt,@JsonKey(name: "read") bool isRead +}); + + + + +} +/// @nodoc +class __$NotificationModelCopyWithImpl<$Res> + implements _$NotificationModelCopyWith<$Res> { + __$NotificationModelCopyWithImpl(this._self, this._then); + + final _NotificationModel _self; + final $Res Function(_NotificationModel) _then; + +/// Create a copy of NotificationModel +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = freezed,Object? userId = freezed,Object? title = freezed,Object? body = null,Object? image = freezed,Object? group = freezed,Object? createdAt = null,Object? isRead = null,}) { + return _then(_NotificationModel( +id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String?,userId: freezed == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable +as String?,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String?,body: null == body ? _self.body : body // ignore: cast_nullable_to_non_nullable +as String,image: freezed == image ? _self.image : image // ignore: cast_nullable_to_non_nullable +as String?,group: freezed == group ? _self.group : group // ignore: cast_nullable_to_non_nullable +as String?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,isRead: null == isRead ? _self.isRead : isRead // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/lib/core/features/notifications/domain/notification.g.dart b/lib/core/features/notifications/domain/notification.g.dart new file mode 100644 index 0000000..465bf53 --- /dev/null +++ b/lib/core/features/notifications/domain/notification.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'notification.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_NotificationModel _$NotificationModelFromJson(Map json) => + _NotificationModel( + id: json['id'] as String?, + userId: json['user_id'] as String?, + title: json['title'] as String?, + body: json['body'] as String, + image: json['image'] as String?, + group: json['group'] as String?, + createdAt: DateTime.parse(json['created_at'] as String), + isRead: json['read'] as bool? ?? false, + ); + +Map _$NotificationModelToJson(_NotificationModel instance) => + { + 'id': ?instance.id, + 'user_id': instance.userId, + 'title': instance.title, + 'body': instance.body, + 'image': instance.image, + 'group': instance.group, + 'created_at': instance.createdAt.toIso8601String(), + 'read': instance.isRead, + }; diff --git a/lib/core/features/notifications/presentation/event_notifications_tile.dart b/lib/core/features/notifications/presentation/event_notifications_tile.dart index 394a89a..21ccf0f 100644 --- a/lib/core/features/notifications/presentation/event_notifications_tile.dart +++ b/lib/core/features/notifications/presentation/event_notifications_tile.dart @@ -16,9 +16,8 @@ class EventNotificationsTile extends ConsumerWidget { return enabledAsync.when( data: (enabled) => SwitchListTile( value: enabled, - onChanged: (value) => ref - .read(eventNotificationsControllerProvider.notifier) - .setEnabled(value, requestPermission: true), + onChanged: (value) => + ref.read(eventNotificationsControllerProvider.notifier).setEnabled(value), title: Text(title), subtitle: subtitle == null ? null : Text(subtitle!), dense: dense, diff --git a/lib/core/features/profile/domain/user_model.dart b/lib/core/features/profile/domain/user_model.dart index 08a9d2e..f97efec 100644 --- a/lib/core/features/profile/domain/user_model.dart +++ b/lib/core/features/profile/domain/user_model.dart @@ -30,6 +30,9 @@ abstract class UserModel with _$UserModel { @JsonKey(includeToJson: false, includeFromJson: false) @Default(false) bool isCachedValue, @JsonKey(name: "agreement_signed_on") DateTime? agreementSignedOn, @JsonKey(name: "fcm_token") String? fcmToken, + @JsonKey(name: "new_event_notifications_enabled") + @Default(true) + bool newEventNotificationsEnabled, }) = _UserModel; factory UserModel.fromJson(Map json) => _$UserModelFromJson(json); diff --git a/lib/core/features/profile/domain/user_model.freezed.dart b/lib/core/features/profile/domain/user_model.freezed.dart index 7c9bd14..6c57c8a 100644 --- a/lib/core/features/profile/domain/user_model.freezed.dart +++ b/lib/core/features/profile/domain/user_model.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$UserModel { - String get id; String get email;@JsonKey(name: "address") int? get addressId;@JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? get address;@JsonKey(name: "first_name") String get firstName;@JsonKey(name: "last_name") String get lastName;@JsonKey(name: "age_group", toJson: AgeGroupJson.toJsonStatic, fromJson: AgeGroupJson.fromJson) AgeGroup get ageGroup; UserRole get role;@JsonKey(name: "phone_number") String? get phoneNumber;@JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? get createdAt;@JsonKey(includeToJson: false, includeFromJson: false) bool get isCachedValue;@JsonKey(name: "agreement_signed_on") DateTime? get agreementSignedOn;@JsonKey(name: "fcm_token") String? get fcmToken; + String get id; String get email;@JsonKey(name: "address") int? get addressId;@JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? get address;@JsonKey(name: "first_name") String get firstName;@JsonKey(name: "last_name") String get lastName;@JsonKey(name: "age_group", toJson: AgeGroupJson.toJsonStatic, fromJson: AgeGroupJson.fromJson) AgeGroup get ageGroup; UserRole get role;@JsonKey(name: "phone_number") String? get phoneNumber;@JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? get createdAt;@JsonKey(includeToJson: false, includeFromJson: false) bool get isCachedValue;@JsonKey(name: "agreement_signed_on") DateTime? get agreementSignedOn;@JsonKey(name: "fcm_token") String? get fcmToken;@JsonKey(name: "new_event_notifications_enabled") bool get newEventNotificationsEnabled; /// Create a copy of UserModel /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -28,16 +28,16 @@ $UserModelCopyWith get copyWith => _$UserModelCopyWithImpl @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is UserModel&&(identical(other.id, id) || other.id == id)&&(identical(other.email, email) || other.email == email)&&(identical(other.addressId, addressId) || other.addressId == addressId)&&(identical(other.address, address) || other.address == address)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.ageGroup, ageGroup) || other.ageGroup == ageGroup)&&(identical(other.role, role) || other.role == role)&&(identical(other.phoneNumber, phoneNumber) || other.phoneNumber == phoneNumber)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isCachedValue, isCachedValue) || other.isCachedValue == isCachedValue)&&(identical(other.agreementSignedOn, agreementSignedOn) || other.agreementSignedOn == agreementSignedOn)&&(identical(other.fcmToken, fcmToken) || other.fcmToken == fcmToken)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is UserModel&&(identical(other.id, id) || other.id == id)&&(identical(other.email, email) || other.email == email)&&(identical(other.addressId, addressId) || other.addressId == addressId)&&(identical(other.address, address) || other.address == address)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.ageGroup, ageGroup) || other.ageGroup == ageGroup)&&(identical(other.role, role) || other.role == role)&&(identical(other.phoneNumber, phoneNumber) || other.phoneNumber == phoneNumber)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isCachedValue, isCachedValue) || other.isCachedValue == isCachedValue)&&(identical(other.agreementSignedOn, agreementSignedOn) || other.agreementSignedOn == agreementSignedOn)&&(identical(other.fcmToken, fcmToken) || other.fcmToken == fcmToken)&&(identical(other.newEventNotificationsEnabled, newEventNotificationsEnabled) || other.newEventNotificationsEnabled == newEventNotificationsEnabled)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,id,email,addressId,address,firstName,lastName,ageGroup,role,phoneNumber,createdAt,isCachedValue,agreementSignedOn,fcmToken); +int get hashCode => Object.hash(runtimeType,id,email,addressId,address,firstName,lastName,ageGroup,role,phoneNumber,createdAt,isCachedValue,agreementSignedOn,fcmToken,newEventNotificationsEnabled); @override String toString() { - return 'UserModel(id: $id, email: $email, addressId: $addressId, address: $address, firstName: $firstName, lastName: $lastName, ageGroup: $ageGroup, role: $role, phoneNumber: $phoneNumber, createdAt: $createdAt, isCachedValue: $isCachedValue, agreementSignedOn: $agreementSignedOn, fcmToken: $fcmToken)'; + return 'UserModel(id: $id, email: $email, addressId: $addressId, address: $address, firstName: $firstName, lastName: $lastName, ageGroup: $ageGroup, role: $role, phoneNumber: $phoneNumber, createdAt: $createdAt, isCachedValue: $isCachedValue, agreementSignedOn: $agreementSignedOn, fcmToken: $fcmToken, newEventNotificationsEnabled: $newEventNotificationsEnabled)'; } @@ -48,7 +48,7 @@ abstract mixin class $UserModelCopyWith<$Res> { factory $UserModelCopyWith(UserModel value, $Res Function(UserModel) _then) = _$UserModelCopyWithImpl; @useResult $Res call({ - String id, String email,@JsonKey(name: "address") int? addressId,@JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address,@JsonKey(name: "first_name") String firstName,@JsonKey(name: "last_name") String lastName,@JsonKey(name: "age_group", toJson: AgeGroupJson.toJsonStatic, fromJson: AgeGroupJson.fromJson) AgeGroup ageGroup, UserRole role,@JsonKey(name: "phone_number") String? phoneNumber,@JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt,@JsonKey(includeToJson: false, includeFromJson: false) bool isCachedValue,@JsonKey(name: "agreement_signed_on") DateTime? agreementSignedOn,@JsonKey(name: "fcm_token") String? fcmToken + String id, String email,@JsonKey(name: "address") int? addressId,@JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address,@JsonKey(name: "first_name") String firstName,@JsonKey(name: "last_name") String lastName,@JsonKey(name: "age_group", toJson: AgeGroupJson.toJsonStatic, fromJson: AgeGroupJson.fromJson) AgeGroup ageGroup, UserRole role,@JsonKey(name: "phone_number") String? phoneNumber,@JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt,@JsonKey(includeToJson: false, includeFromJson: false) bool isCachedValue,@JsonKey(name: "agreement_signed_on") DateTime? agreementSignedOn,@JsonKey(name: "fcm_token") String? fcmToken,@JsonKey(name: "new_event_notifications_enabled") bool newEventNotificationsEnabled }); @@ -65,7 +65,7 @@ class _$UserModelCopyWithImpl<$Res> /// Create a copy of UserModel /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? email = null,Object? addressId = freezed,Object? address = freezed,Object? firstName = null,Object? lastName = null,Object? ageGroup = null,Object? role = null,Object? phoneNumber = freezed,Object? createdAt = freezed,Object? isCachedValue = null,Object? agreementSignedOn = freezed,Object? fcmToken = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? email = null,Object? addressId = freezed,Object? address = freezed,Object? firstName = null,Object? lastName = null,Object? ageGroup = null,Object? role = null,Object? phoneNumber = freezed,Object? createdAt = freezed,Object? isCachedValue = null,Object? agreementSignedOn = freezed,Object? fcmToken = freezed,Object? newEventNotificationsEnabled = null,}) { return _then(_self.copyWith( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable @@ -80,7 +80,8 @@ as String?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // igno as DateTime?,isCachedValue: null == isCachedValue ? _self.isCachedValue : isCachedValue // ignore: cast_nullable_to_non_nullable as bool,agreementSignedOn: freezed == agreementSignedOn ? _self.agreementSignedOn : agreementSignedOn // ignore: cast_nullable_to_non_nullable as DateTime?,fcmToken: freezed == fcmToken ? _self.fcmToken : fcmToken // ignore: cast_nullable_to_non_nullable -as String?, +as String?,newEventNotificationsEnabled: null == newEventNotificationsEnabled ? _self.newEventNotificationsEnabled : newEventNotificationsEnabled // ignore: cast_nullable_to_non_nullable +as bool, )); } /// Create a copy of UserModel @@ -177,10 +178,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String email, @JsonKey(name: "address") int? addressId, @JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address, @JsonKey(name: "first_name") String firstName, @JsonKey(name: "last_name") String lastName, @JsonKey(name: "age_group", toJson: AgeGroupJson.toJsonStatic, fromJson: AgeGroupJson.fromJson) AgeGroup ageGroup, UserRole role, @JsonKey(name: "phone_number") String? phoneNumber, @JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt, @JsonKey(includeToJson: false, includeFromJson: false) bool isCachedValue, @JsonKey(name: "agreement_signed_on") DateTime? agreementSignedOn, @JsonKey(name: "fcm_token") String? fcmToken)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String email, @JsonKey(name: "address") int? addressId, @JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address, @JsonKey(name: "first_name") String firstName, @JsonKey(name: "last_name") String lastName, @JsonKey(name: "age_group", toJson: AgeGroupJson.toJsonStatic, fromJson: AgeGroupJson.fromJson) AgeGroup ageGroup, UserRole role, @JsonKey(name: "phone_number") String? phoneNumber, @JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt, @JsonKey(includeToJson: false, includeFromJson: false) bool isCachedValue, @JsonKey(name: "agreement_signed_on") DateTime? agreementSignedOn, @JsonKey(name: "fcm_token") String? fcmToken, @JsonKey(name: "new_event_notifications_enabled") bool newEventNotificationsEnabled)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _UserModel() when $default != null: -return $default(_that.id,_that.email,_that.addressId,_that.address,_that.firstName,_that.lastName,_that.ageGroup,_that.role,_that.phoneNumber,_that.createdAt,_that.isCachedValue,_that.agreementSignedOn,_that.fcmToken);case _: +return $default(_that.id,_that.email,_that.addressId,_that.address,_that.firstName,_that.lastName,_that.ageGroup,_that.role,_that.phoneNumber,_that.createdAt,_that.isCachedValue,_that.agreementSignedOn,_that.fcmToken,_that.newEventNotificationsEnabled);case _: return orElse(); } @@ -198,10 +199,10 @@ return $default(_that.id,_that.email,_that.addressId,_that.address,_that.firstNa /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String id, String email, @JsonKey(name: "address") int? addressId, @JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address, @JsonKey(name: "first_name") String firstName, @JsonKey(name: "last_name") String lastName, @JsonKey(name: "age_group", toJson: AgeGroupJson.toJsonStatic, fromJson: AgeGroupJson.fromJson) AgeGroup ageGroup, UserRole role, @JsonKey(name: "phone_number") String? phoneNumber, @JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt, @JsonKey(includeToJson: false, includeFromJson: false) bool isCachedValue, @JsonKey(name: "agreement_signed_on") DateTime? agreementSignedOn, @JsonKey(name: "fcm_token") String? fcmToken) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String id, String email, @JsonKey(name: "address") int? addressId, @JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address, @JsonKey(name: "first_name") String firstName, @JsonKey(name: "last_name") String lastName, @JsonKey(name: "age_group", toJson: AgeGroupJson.toJsonStatic, fromJson: AgeGroupJson.fromJson) AgeGroup ageGroup, UserRole role, @JsonKey(name: "phone_number") String? phoneNumber, @JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt, @JsonKey(includeToJson: false, includeFromJson: false) bool isCachedValue, @JsonKey(name: "agreement_signed_on") DateTime? agreementSignedOn, @JsonKey(name: "fcm_token") String? fcmToken, @JsonKey(name: "new_event_notifications_enabled") bool newEventNotificationsEnabled) $default,) {final _that = this; switch (_that) { case _UserModel(): -return $default(_that.id,_that.email,_that.addressId,_that.address,_that.firstName,_that.lastName,_that.ageGroup,_that.role,_that.phoneNumber,_that.createdAt,_that.isCachedValue,_that.agreementSignedOn,_that.fcmToken);case _: +return $default(_that.id,_that.email,_that.addressId,_that.address,_that.firstName,_that.lastName,_that.ageGroup,_that.role,_that.phoneNumber,_that.createdAt,_that.isCachedValue,_that.agreementSignedOn,_that.fcmToken,_that.newEventNotificationsEnabled);case _: throw StateError('Unexpected subclass'); } @@ -218,10 +219,10 @@ return $default(_that.id,_that.email,_that.addressId,_that.address,_that.firstNa /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String email, @JsonKey(name: "address") int? addressId, @JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address, @JsonKey(name: "first_name") String firstName, @JsonKey(name: "last_name") String lastName, @JsonKey(name: "age_group", toJson: AgeGroupJson.toJsonStatic, fromJson: AgeGroupJson.fromJson) AgeGroup ageGroup, UserRole role, @JsonKey(name: "phone_number") String? phoneNumber, @JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt, @JsonKey(includeToJson: false, includeFromJson: false) bool isCachedValue, @JsonKey(name: "agreement_signed_on") DateTime? agreementSignedOn, @JsonKey(name: "fcm_token") String? fcmToken)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String email, @JsonKey(name: "address") int? addressId, @JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address, @JsonKey(name: "first_name") String firstName, @JsonKey(name: "last_name") String lastName, @JsonKey(name: "age_group", toJson: AgeGroupJson.toJsonStatic, fromJson: AgeGroupJson.fromJson) AgeGroup ageGroup, UserRole role, @JsonKey(name: "phone_number") String? phoneNumber, @JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt, @JsonKey(includeToJson: false, includeFromJson: false) bool isCachedValue, @JsonKey(name: "agreement_signed_on") DateTime? agreementSignedOn, @JsonKey(name: "fcm_token") String? fcmToken, @JsonKey(name: "new_event_notifications_enabled") bool newEventNotificationsEnabled)? $default,) {final _that = this; switch (_that) { case _UserModel() when $default != null: -return $default(_that.id,_that.email,_that.addressId,_that.address,_that.firstName,_that.lastName,_that.ageGroup,_that.role,_that.phoneNumber,_that.createdAt,_that.isCachedValue,_that.agreementSignedOn,_that.fcmToken);case _: +return $default(_that.id,_that.email,_that.addressId,_that.address,_that.firstName,_that.lastName,_that.ageGroup,_that.role,_that.phoneNumber,_that.createdAt,_that.isCachedValue,_that.agreementSignedOn,_that.fcmToken,_that.newEventNotificationsEnabled);case _: return null; } @@ -233,7 +234,7 @@ return $default(_that.id,_that.email,_that.addressId,_that.address,_that.firstNa @JsonSerializable() class _UserModel extends UserModel { - const _UserModel({required this.id, required this.email, @JsonKey(name: "address") this.addressId, @JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) this.address, @JsonKey(name: "first_name") required this.firstName, @JsonKey(name: "last_name") required this.lastName, @JsonKey(name: "age_group", toJson: AgeGroupJson.toJsonStatic, fromJson: AgeGroupJson.fromJson) required this.ageGroup, required this.role, @JsonKey(name: "phone_number") this.phoneNumber, @JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") this.createdAt, @JsonKey(includeToJson: false, includeFromJson: false) this.isCachedValue = false, @JsonKey(name: "agreement_signed_on") this.agreementSignedOn, @JsonKey(name: "fcm_token") this.fcmToken}): super._(); + const _UserModel({required this.id, required this.email, @JsonKey(name: "address") this.addressId, @JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) this.address, @JsonKey(name: "first_name") required this.firstName, @JsonKey(name: "last_name") required this.lastName, @JsonKey(name: "age_group", toJson: AgeGroupJson.toJsonStatic, fromJson: AgeGroupJson.fromJson) required this.ageGroup, required this.role, @JsonKey(name: "phone_number") this.phoneNumber, @JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") this.createdAt, @JsonKey(includeToJson: false, includeFromJson: false) this.isCachedValue = false, @JsonKey(name: "agreement_signed_on") this.agreementSignedOn, @JsonKey(name: "fcm_token") this.fcmToken, @JsonKey(name: "new_event_notifications_enabled") this.newEventNotificationsEnabled = true}): super._(); factory _UserModel.fromJson(Map json) => _$UserModelFromJson(json); @override final String id; @@ -249,6 +250,7 @@ class _UserModel extends UserModel { @override@JsonKey(includeToJson: false, includeFromJson: false) final bool isCachedValue; @override@JsonKey(name: "agreement_signed_on") final DateTime? agreementSignedOn; @override@JsonKey(name: "fcm_token") final String? fcmToken; +@override@JsonKey(name: "new_event_notifications_enabled") final bool newEventNotificationsEnabled; /// Create a copy of UserModel /// with the given fields replaced by the non-null parameter values. @@ -263,16 +265,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _UserModel&&(identical(other.id, id) || other.id == id)&&(identical(other.email, email) || other.email == email)&&(identical(other.addressId, addressId) || other.addressId == addressId)&&(identical(other.address, address) || other.address == address)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.ageGroup, ageGroup) || other.ageGroup == ageGroup)&&(identical(other.role, role) || other.role == role)&&(identical(other.phoneNumber, phoneNumber) || other.phoneNumber == phoneNumber)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isCachedValue, isCachedValue) || other.isCachedValue == isCachedValue)&&(identical(other.agreementSignedOn, agreementSignedOn) || other.agreementSignedOn == agreementSignedOn)&&(identical(other.fcmToken, fcmToken) || other.fcmToken == fcmToken)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _UserModel&&(identical(other.id, id) || other.id == id)&&(identical(other.email, email) || other.email == email)&&(identical(other.addressId, addressId) || other.addressId == addressId)&&(identical(other.address, address) || other.address == address)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.ageGroup, ageGroup) || other.ageGroup == ageGroup)&&(identical(other.role, role) || other.role == role)&&(identical(other.phoneNumber, phoneNumber) || other.phoneNumber == phoneNumber)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isCachedValue, isCachedValue) || other.isCachedValue == isCachedValue)&&(identical(other.agreementSignedOn, agreementSignedOn) || other.agreementSignedOn == agreementSignedOn)&&(identical(other.fcmToken, fcmToken) || other.fcmToken == fcmToken)&&(identical(other.newEventNotificationsEnabled, newEventNotificationsEnabled) || other.newEventNotificationsEnabled == newEventNotificationsEnabled)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,id,email,addressId,address,firstName,lastName,ageGroup,role,phoneNumber,createdAt,isCachedValue,agreementSignedOn,fcmToken); +int get hashCode => Object.hash(runtimeType,id,email,addressId,address,firstName,lastName,ageGroup,role,phoneNumber,createdAt,isCachedValue,agreementSignedOn,fcmToken,newEventNotificationsEnabled); @override String toString() { - return 'UserModel(id: $id, email: $email, addressId: $addressId, address: $address, firstName: $firstName, lastName: $lastName, ageGroup: $ageGroup, role: $role, phoneNumber: $phoneNumber, createdAt: $createdAt, isCachedValue: $isCachedValue, agreementSignedOn: $agreementSignedOn, fcmToken: $fcmToken)'; + return 'UserModel(id: $id, email: $email, addressId: $addressId, address: $address, firstName: $firstName, lastName: $lastName, ageGroup: $ageGroup, role: $role, phoneNumber: $phoneNumber, createdAt: $createdAt, isCachedValue: $isCachedValue, agreementSignedOn: $agreementSignedOn, fcmToken: $fcmToken, newEventNotificationsEnabled: $newEventNotificationsEnabled)'; } @@ -283,7 +285,7 @@ abstract mixin class _$UserModelCopyWith<$Res> implements $UserModelCopyWith<$Re factory _$UserModelCopyWith(_UserModel value, $Res Function(_UserModel) _then) = __$UserModelCopyWithImpl; @override @useResult $Res call({ - String id, String email,@JsonKey(name: "address") int? addressId,@JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address,@JsonKey(name: "first_name") String firstName,@JsonKey(name: "last_name") String lastName,@JsonKey(name: "age_group", toJson: AgeGroupJson.toJsonStatic, fromJson: AgeGroupJson.fromJson) AgeGroup ageGroup, UserRole role,@JsonKey(name: "phone_number") String? phoneNumber,@JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt,@JsonKey(includeToJson: false, includeFromJson: false) bool isCachedValue,@JsonKey(name: "agreement_signed_on") DateTime? agreementSignedOn,@JsonKey(name: "fcm_token") String? fcmToken + String id, String email,@JsonKey(name: "address") int? addressId,@JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address,@JsonKey(name: "first_name") String firstName,@JsonKey(name: "last_name") String lastName,@JsonKey(name: "age_group", toJson: AgeGroupJson.toJsonStatic, fromJson: AgeGroupJson.fromJson) AgeGroup ageGroup, UserRole role,@JsonKey(name: "phone_number") String? phoneNumber,@JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt,@JsonKey(includeToJson: false, includeFromJson: false) bool isCachedValue,@JsonKey(name: "agreement_signed_on") DateTime? agreementSignedOn,@JsonKey(name: "fcm_token") String? fcmToken,@JsonKey(name: "new_event_notifications_enabled") bool newEventNotificationsEnabled }); @@ -300,7 +302,7 @@ class __$UserModelCopyWithImpl<$Res> /// Create a copy of UserModel /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? email = null,Object? addressId = freezed,Object? address = freezed,Object? firstName = null,Object? lastName = null,Object? ageGroup = null,Object? role = null,Object? phoneNumber = freezed,Object? createdAt = freezed,Object? isCachedValue = null,Object? agreementSignedOn = freezed,Object? fcmToken = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? email = null,Object? addressId = freezed,Object? address = freezed,Object? firstName = null,Object? lastName = null,Object? ageGroup = null,Object? role = null,Object? phoneNumber = freezed,Object? createdAt = freezed,Object? isCachedValue = null,Object? agreementSignedOn = freezed,Object? fcmToken = freezed,Object? newEventNotificationsEnabled = null,}) { return _then(_UserModel( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable @@ -315,7 +317,8 @@ as String?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // igno as DateTime?,isCachedValue: null == isCachedValue ? _self.isCachedValue : isCachedValue // ignore: cast_nullable_to_non_nullable as bool,agreementSignedOn: freezed == agreementSignedOn ? _self.agreementSignedOn : agreementSignedOn // ignore: cast_nullable_to_non_nullable as DateTime?,fcmToken: freezed == fcmToken ? _self.fcmToken : fcmToken // ignore: cast_nullable_to_non_nullable -as String?, +as String?,newEventNotificationsEnabled: null == newEventNotificationsEnabled ? _self.newEventNotificationsEnabled : newEventNotificationsEnabled // ignore: cast_nullable_to_non_nullable +as bool, )); } diff --git a/lib/core/features/profile/domain/user_model.g.dart b/lib/core/features/profile/domain/user_model.g.dart index 2eb44e4..49db56e 100644 --- a/lib/core/features/profile/domain/user_model.g.dart +++ b/lib/core/features/profile/domain/user_model.g.dart @@ -25,6 +25,8 @@ _UserModel _$UserModelFromJson(Map json) => _UserModel( ? null : DateTime.parse(json['agreement_signed_on'] as String), fcmToken: json['fcm_token'] as String?, + newEventNotificationsEnabled: + json['new_event_notifications_enabled'] as bool? ?? true, ); Map _$UserModelToJson(_UserModel instance) => @@ -39,6 +41,7 @@ Map _$UserModelToJson(_UserModel instance) => 'phone_number': instance.phoneNumber, 'agreement_signed_on': instance.agreementSignedOn?.toIso8601String(), 'fcm_token': instance.fcmToken, + 'new_event_notifications_enabled': instance.newEventNotificationsEnabled, }; const _$UserRoleEnumMap = { diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 4c63a78..32354e4 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import file_selector_macos import firebase_core import firebase_messaging import firebase_remote_config +import package_info_plus import path_provider_foundation import posthog_flutter import share_plus @@ -22,6 +23,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FirebaseRemoteConfigPlugin.register(with: registry.registrar(forPlugin: "FirebaseRemoteConfigPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PosthogFlutterPlugin.register(with: registry.registrar(forPlugin: "PosthogFlutterPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 64c9695..f371ee6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -499,14 +499,30 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_nominatim: + flutter_osm_interface: dependency: "direct main" description: - name: flutter_nominatim - sha256: "37f52d8ead24b97a7a6fb46fac743ca79109b50103bdf991e4af76005ec46bc2" + name: flutter_osm_interface + sha256: fe7b02fee205aec861d0f08f643d4d6c49a5b9df06d3b43919fdf6699a275829 url: "https://pub.dev" source: hosted - version: "0.0.1+2" + version: "1.4.0" + flutter_osm_plugin: + dependency: "direct main" + description: + name: flutter_osm_plugin + sha256: f926a711cc193fed5c9aacde79603d81cd54c93ea19d35e553b5cec062b6bb46 + url: "https://pub.dev" + source: hosted + version: "1.4.3" + flutter_osm_web: + dependency: transitive + description: + name: flutter_osm_web + sha256: "570e714db452f43480fc81f48592139a48865c75a3092a19e4c536416b154bf5" + url: "https://pub.dev" + source: hosted + version: "1.4.2" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -597,6 +613,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.1.3" + google_polyline_algorithm: + dependency: transitive + description: + name: google_polyline_algorithm + sha256: "357874f00d3f93c3ba1bf4b4d9a154aa9ee87147c068238c1e8392012b686a03" + url: "https://pub.dev" + source: hosted + version: "3.1.0" gotrue: dependency: transitive description: @@ -869,6 +893,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.dev" + source: hosted + version: "9.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -925,6 +965,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: transitive + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.dev" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: @@ -1053,6 +1141,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + routing_client_dart: + dependency: transitive + description: + name: routing_client_dart + sha256: "4e2acf1145bb8056e04fd93101a9f2bab2fd769c9d972f7f403f9733a670cb33" + url: "https://pub.dev" + source: hosted + version: "0.5.5" rxdart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8f1ce79..1b85b59 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,7 +29,7 @@ dependencies: table_calendar: ^3.1.2 font_awesome_flutter: ^10.7.0 form_builder_validators: ^11.1.2 - flutter_nominatim: ^0.0.1+2 + flutter_osm_plugin: ^1.4.3 freezed_annotation: ^3.0.0 go_router: ^15.1.2 image: ^4.5.4 @@ -46,6 +46,7 @@ dependencies: supabase_flutter: ^2.9.1 url_launcher: ^6.3.2 vector_math: ^2.2.0 + flutter_osm_interface: ^1.4.0 dev_dependencies: build_runner: ^2.10.4 diff --git a/supabase/functions/push/index.ts b/supabase/functions/push/index.ts index 1e6ad27..96e9d55 100644 --- a/supabase/functions/push/index.ts +++ b/supabase/functions/push/index.ts @@ -35,32 +35,49 @@ Deno.serve(async (req: Request) => { } const event = payload.record; - if (!event || !event.user_id) { - return new Response( - JSON.stringify({ ok: false, error: "missing event.user_id" }), - { headers: { "Content-Type": "application/json" }, status: 400 } - ); + if (!event) { + return new Response(JSON.stringify({ ok: false, error: "missing event" }), { + headers: { "Content-Type": "application/json" }, + status: 400, + }); } const title = event.name ?? "New event"; const body = event.description ?? ""; const image = event.image_url ?? null; - const userId = event.user_id; - // Insert a notification row + const { data: users, error: usersError } = await supabase + .from("users") + .select("id, fcm_token") + .eq("new_event_notifications_enabled", true); + + if (usersError) { + console.error("user lookup error:", usersError); + return new Response(JSON.stringify({ ok: false, error: "user_lookup_failed" }), { + headers: { "Content-Type": "application/json" }, + status: 500, + }); + } + + if (!users || users.length === 0) { + return new Response(JSON.stringify({ ok: true, message: "no_users_enabled" }), { + headers: { "Content-Type": "application/json" }, + }); + } + + const notificationsToInsert = users.map((user) => ({ + user_id: user.id, + title, + body, + image, + group: "new_event", + read: false, + })); + const { data: notifData, error: notifError } = await supabase .from("notifications") - .insert([ - { - user_id: userId, - title, - body, - image, - group: "events", - }, - ]) - .select() - .maybeSingle(); + .insert(notificationsToInsert) + .select(); if (notifError) { console.error("insert notification error:", notifError); @@ -70,33 +87,23 @@ Deno.serve(async (req: Request) => { ); } - const notification = Array.isArray(notifData) ? notifData[0] : notifData; - - // Lookup user's fcm token from users table - const { data: userData, error: userError } = await supabase - .from("users") - .select("fcm_token") - .eq("id", userId) - .single(); - - if (userError) { - console.error("profile lookup error:", userError); - return new Response( - JSON.stringify({ ok: false, error: "user_lookup_failed" }), - { headers: { "Content-Type": "application/json" }, status: 200 } - ); + const notificationByUserId = new Map(); + for (const row of notifData ?? []) { + if (row?.user_id) notificationByUserId.set(row.user_id, row); } - const fcmToken = userData?.fcm_token as string | undefined; - if (!fcmToken) { - console.info("no fcm token for user", userId); + const usersWithTokens = users.filter((user) => user.fcm_token); + if (usersWithTokens.length === 0) { return new Response( - JSON.stringify({ ok: true, notification_id: notification?.id, message: "no_fcm_token" }), + JSON.stringify({ + ok: true, + notification_count: notifData?.length ?? 0, + message: "no_fcm_tokens", + }), { headers: { "Content-Type": "application/json" } } ); } - // Get access token for FCM const accessToken = await getAccessToken({ clientEmail: serviceAccount.client_email, privateKey: serviceAccount.private_key, @@ -105,53 +112,57 @@ Deno.serve(async (req: Request) => { const projectId = serviceAccount.project_id; const fcmUrl = `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`; - const fcmPayload = { - message: { - token: fcmToken, - notification: { - title, - body, + const results: Array<{ user_id: string; ok: boolean; error?: unknown }> = []; + + for (const user of usersWithTokens) { + const notification = notificationByUserId.get(user.id); + const fcmPayload = { + message: { + token: user.fcm_token, + notification: { + title, + body, + ...(image ? { image } : {}), + }, + data: { + notification_id: notification?.id ?? "", + event_id: String(event.id ?? ""), + source: "events", + image: image ?? "", + }, }, - data: { - notification_id: notification?.id ?? "", - event_id: String(event.id ?? ""), - source: "events", - }, - }, - }; - - const fcmRes = await fetch(fcmUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify(fcmPayload), - }); - - const fcmResJson = await fcmRes.json(); - - if (!fcmRes.ok) { - console.error("FCM error:", fcmRes.status, fcmResJson); - // update notification with failure info (best-effort) - await supabase - .from("notifications") - .update({ group: "events_failed", image: JSON.stringify(fcmResJson) }) - .eq("id", notification?.id); - return new Response(JSON.stringify({ ok: false, fcm_error: fcmResJson }), { - headers: { "Content-Type": "application/json" }, - status: 500, - }); + }; + + try { + const fcmRes = await fetch(fcmUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(fcmPayload), + }); + + const fcmResJson = await fcmRes.json(); + + if (!fcmRes.ok) { + console.error("FCM error:", fcmRes.status, fcmResJson); + results.push({ user_id: user.id, ok: false, error: fcmResJson }); + } else { + results.push({ user_id: user.id, ok: true }); + } + } catch (err) { + console.error("FCM send error:", err); + results.push({ user_id: user.id, ok: false, error: String(err) }); + } } - // Optionally mark notification sent (you may add a status column if desired) - await supabase - .from("notifications") - .update({ group: "events_sent" }) - .eq("id", notification?.id); - return new Response( - JSON.stringify({ ok: true, notification_id: notification?.id, fcm_response: fcmResJson }), + JSON.stringify({ + ok: true, + notification_count: notifData?.length ?? 0, + send_results: results, + }), { headers: { "Content-Type": "application/json" } } ); } catch (err) { @@ -176,4 +187,4 @@ function getAccessToken({ clientEmail, privateKey }: { clientEmail: string; priv resolve(tokens.access_token); }); }); -} \ No newline at end of file +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 3556b81..6ff4bc9 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSelectorWindows")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 3bdc031..eefe072 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links file_selector_windows firebase_core + permission_handler_windows share_plus url_launcher_windows ) From 286e8febd6c6319d8f06439bb17aa44070820039 Mon Sep 17 00:00:00 2001 From: alexdivadi Date: Thu, 19 Feb 2026 23:25:38 -0600 Subject: [PATCH 4/5] feat: add toasts and rename address field to form --- .../widgets/inputs/address_modal_tile.dart | 4 ++-- ..._field.dart => any_step_address_form.dart} | 19 +++++++++++---- lib/core/common/widgets/inputs/inputs.dart | 2 +- .../event_notifications_tile.dart | 15 ++++++++++-- lib/l10n/app_en.arb | 10 ++++++++ lib/l10n/app_es.arb | 10 ++++++++ lib/l10n/generated/app_localizations.dart | 24 +++++++++++++++++++ lib/l10n/generated/app_localizations_en.dart | 13 ++++++++++ lib/l10n/generated/app_localizations_es.dart | 14 +++++++++++ 9 files changed, 101 insertions(+), 10 deletions(-) rename lib/core/common/widgets/inputs/{any_step_address_field.dart => any_step_address_form.dart} (96%) diff --git a/lib/core/common/widgets/inputs/address_modal_tile.dart b/lib/core/common/widgets/inputs/address_modal_tile.dart index 5b8d31e..6492df9 100644 --- a/lib/core/common/widgets/inputs/address_modal_tile.dart +++ b/lib/core/common/widgets/inputs/address_modal_tile.dart @@ -1,6 +1,6 @@ import 'package:anystep/core/common/constants/spacing.dart'; import 'package:anystep/core/common/widgets/any_step_modal.dart'; -import 'package:anystep/core/common/widgets/inputs/any_step_address_field.dart'; +import 'package:anystep/core/common/widgets/inputs/any_step_address_form.dart'; import 'package:anystep/core/features/location/data/address_repository.dart'; import 'package:anystep/core/features/location/domain/address_model.dart'; import 'package:anystep/l10n/generated/app_localizations.dart'; @@ -282,7 +282,7 @@ class _AddressModalContentState extends ConsumerState<_AddressModalContent> { ], ), const SizedBox(height: AnyStepSpacing.sm8), - AnyStepAddressField( + AnyStepAddressForm( formKey: _formKey, initialAddressId: widget.initialAddressId, countryCode: widget.countryCode, diff --git a/lib/core/common/widgets/inputs/any_step_address_field.dart b/lib/core/common/widgets/inputs/any_step_address_form.dart similarity index 96% rename from lib/core/common/widgets/inputs/any_step_address_field.dart rename to lib/core/common/widgets/inputs/any_step_address_form.dart index f9cdeba..7421330 100644 --- a/lib/core/common/widgets/inputs/any_step_address_field.dart +++ b/lib/core/common/widgets/inputs/any_step_address_form.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:anystep/core/common/constants/spacing.dart'; import 'package:anystep/core/common/utils/log_utils.dart'; +import 'package:anystep/core/common/utils/snackbar_message.dart'; import 'package:anystep/core/common/utils/state_utils.dart'; import 'package:anystep/core/common/widgets/inputs/any_step_text_field.dart'; import 'package:anystep/core/features/location/data/address_repository.dart'; @@ -15,8 +16,8 @@ import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:form_builder_validators/form_builder_validators.dart'; -class AnyStepAddressField extends ConsumerStatefulWidget { - const AnyStepAddressField({ +class AnyStepAddressForm extends ConsumerStatefulWidget { + const AnyStepAddressForm({ super.key, required this.formKey, this.countryCode = 'US', @@ -84,10 +85,10 @@ class AnyStepAddressField extends ConsumerStatefulWidget { final String? initialPostalCode; @override - ConsumerState createState() => _AnyStepAddressFieldState(); + ConsumerState createState() => _AnyStepAddressFormState(); } -class _AnyStepAddressFieldState extends ConsumerState { +class _AnyStepAddressFormState extends ConsumerState { final TextEditingController _streetController = TextEditingController(); final FocusNode _streetFocusNode = FocusNode(); final FocusNode _cityFocusNode = FocusNode(); @@ -123,7 +124,7 @@ class _AnyStepAddressFieldState extends ConsumerState { } @override - void didUpdateWidget(covariant AnyStepAddressField oldWidget) { + void didUpdateWidget(covariant AnyStepAddressForm oldWidget) { super.didUpdateWidget(oldWidget); if (widget.disableSearch && !_isSearchDisabled) { _isSearchDisabled = true; @@ -400,8 +401,16 @@ class _AnyStepAddressFieldState extends ConsumerState { _addressId = saved.id; form.fields[widget.addressIdFieldName]?.didChange(saved.id); widget.onAddressSaved?.call(saved.id); + if (mounted) { + final loc = AppLocalizations.of(context); + context.showSuccessSnackbar(loc.addressSaved); + } } catch (e, stackTrace) { Log.e('Error saving address', e, stackTrace); + if (mounted) { + final loc = AppLocalizations.of(context); + context.showErrorSnackbar(loc.addressSaveFailed); + } } finally { if (mounted) setState(() => _isSaving = false); } diff --git a/lib/core/common/widgets/inputs/inputs.dart b/lib/core/common/widgets/inputs/inputs.dart index af19ea5..ef23b33 100644 --- a/lib/core/common/widgets/inputs/inputs.dart +++ b/lib/core/common/widgets/inputs/inputs.dart @@ -2,7 +2,7 @@ export 'any_step_segment_control.dart'; export 'any_step_text_field.dart'; export 'any_step_date_time_picker.dart'; export 'any_step_switch_input.dart'; -export 'any_step_address_field.dart'; +export 'any_step_address_form.dart'; export 'address_modal_tile.dart'; export 'address_autocomplete_field.dart'; export 'image_upload_widget.dart'; diff --git a/lib/core/features/notifications/presentation/event_notifications_tile.dart b/lib/core/features/notifications/presentation/event_notifications_tile.dart index 21ccf0f..ce93838 100644 --- a/lib/core/features/notifications/presentation/event_notifications_tile.dart +++ b/lib/core/features/notifications/presentation/event_notifications_tile.dart @@ -1,4 +1,6 @@ import 'package:anystep/core/features/notifications/data/event_notifications_controller.dart'; +import 'package:anystep/core/common/utils/snackbar_message.dart'; +import 'package:anystep/l10n/generated/app_localizations.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -12,12 +14,21 @@ class EventNotificationsTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final enabledAsync = ref.watch(eventNotificationsControllerProvider); + final loc = AppLocalizations.of(context); return enabledAsync.when( data: (enabled) => SwitchListTile( value: enabled, - onChanged: (value) => - ref.read(eventNotificationsControllerProvider.notifier).setEnabled(value), + onChanged: (value) async { + try { + await ref.read(eventNotificationsControllerProvider.notifier).setEnabled(value); + if (!context.mounted) return; + context.showSuccessSnackbar(loc.notificationSettingsUpdated); + } catch (_) { + if (!context.mounted) return; + context.showErrorSnackbar(loc.notificationSettingsUpdateFailed); + } + }, title: Text(title), subtitle: subtitle == null ? null : Text(subtitle!), dense: dense, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0e656fd..4dfbfb1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -108,6 +108,16 @@ "@address": {"description": "Section title for address"}, "noAddressProvided": "No address provided", "@noAddressProvided": {"description": "Fallback when no address is provided"}, + "addressSaved": "Address saved.", + "@addressSaved": {"description": "Toast message after saving an address"}, + "addressSaveFailed": "Couldn't save address.", + "@addressSaveFailed": {"description": "Toast message after failing to save an address"}, + "notificationSettingsUpdated": "Notification settings updated.", + "@notificationSettingsUpdated": {"description": "Toast message after updating notification settings"}, + "notificationSettingsUpdateFailed": "Couldn't update notification settings.", + "@notificationSettingsUpdateFailed": { + "description": "Toast message after failing to update notification settings" + }, "start": "Start", "@start": {"description": "Event start label"}, "end": "End", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index fb2c54f..d7fd807 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -107,6 +107,16 @@ "@address": {"description": "Título de sección para dirección"}, "noAddressProvided": "No se proporcionó dirección", "@noAddressProvided": {"description": "Texto cuando no hay dirección"}, + "addressSaved": "Dirección guardada.", + "@addressSaved": {"description": "Toast message after saving an address"}, + "addressSaveFailed": "No se pudo guardar la dirección.", + "@addressSaveFailed": {"description": "Toast message after failing to save an address"}, + "notificationSettingsUpdated": "Configuración de notificaciones actualizada.", + "@notificationSettingsUpdated": {"description": "Toast message after updating notification settings"}, + "notificationSettingsUpdateFailed": "No se pudo actualizar la configuración de notificaciones.", + "@notificationSettingsUpdateFailed": { + "description": "Toast message after failing to update notification settings" + }, "start": "Inicio", "@start": {"description": "Etiqueta de inicio del evento"}, "end": "Fin", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 908702a..0b360a2 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -338,6 +338,30 @@ abstract class AppLocalizations { /// **'No address provided'** String get noAddressProvided; + /// Toast message after saving an address + /// + /// In en, this message translates to: + /// **'Address saved.'** + String get addressSaved; + + /// Toast message after failing to save an address + /// + /// In en, this message translates to: + /// **'Couldn\'t save address.'** + String get addressSaveFailed; + + /// Toast message after updating notification settings + /// + /// In en, this message translates to: + /// **'Notification settings updated.'** + String get notificationSettingsUpdated; + + /// Toast message after failing to update notification settings + /// + /// In en, this message translates to: + /// **'Couldn\'t update notification settings.'** + String get notificationSettingsUpdateFailed; + /// Event start label /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index aa0d2e3..8002cd5 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -146,6 +146,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get noAddressProvided => 'No address provided'; + @override + String get addressSaved => 'Address saved.'; + + @override + String get addressSaveFailed => 'Couldn\'t save address.'; + + @override + String get notificationSettingsUpdated => 'Notification settings updated.'; + + @override + String get notificationSettingsUpdateFailed => + 'Couldn\'t update notification settings.'; + @override String get start => 'Start'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 2805f31..b3cdcc3 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -147,6 +147,20 @@ class AppLocalizationsEs extends AppLocalizations { @override String get noAddressProvided => 'No se proporcionó dirección'; + @override + String get addressSaved => 'Dirección guardada.'; + + @override + String get addressSaveFailed => 'No se pudo guardar la dirección.'; + + @override + String get notificationSettingsUpdated => + 'Configuración de notificaciones actualizada.'; + + @override + String get notificationSettingsUpdateFailed => + 'No se pudo actualizar la configuración de notificaciones.'; + @override String get start => 'Inicio'; From 7855d944567270369272eabced1b7f66e14f444e Mon Sep 17 00:00:00 2001 From: alexdivadi Date: Thu, 19 Feb 2026 23:28:58 -0600 Subject: [PATCH 5/5] chore: update readme --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index e885e6a..dca7397 100644 --- a/README.md +++ b/README.md @@ -49,3 +49,16 @@ English (`en`) and Spanish (`es`) are enabled via `AppLocalizations.supportedLoc - Placeholders require an `example` string in metadata. - Changing ARB files requires a full restart for hot reload to pick up generated code. - If `lib/l10n/untranslated_messages.txt` is non-empty, add translations before release. + +## Supabase + +To download latest changes to functions: + +```bash +supabase functions download +``` + +To deploy local changes to functions: +```bash +supabase functions deploy push +```