From 044e4cef57c384f0a26a44a2e5cc04776e6e6be8 Mon Sep 17 00:00:00 2001 From: alexdivadi Date: Sat, 14 Feb 2026 17:16:22 -0600 Subject: [PATCH 1/7] feat: enhance event configuration options --- lib/core/app.dart | 4 +- .../inputs/any_step_date_time_picker.dart | 17 +- .../widgets/inputs/any_step_switch_input.dart | 78 +++++++++ .../widgets/inputs/any_step_text_field.dart | 5 + lib/core/common/widgets/inputs/inputs.dart | 1 + lib/core/config/router/router.g.dart | 2 +- lib/core/config/router/routes.dart | 5 + lib/core/config/theme/theme.dart | 46 ++++-- lib/core/features/events/domain/event.dart | 6 +- .../features/events/domain/event.freezed.dart | 52 +++--- lib/core/features/events/domain/event.g.dart | 12 +- .../event_detail/event_detail_form.dart | 87 ++++++++-- .../event_detail_form_controller.dart | 14 ++ .../event_detail_form_controller.g.dart | 2 +- .../event_detail/event_detail_info.dart | 40 +++++ .../widgets/event_time_table.dart | 152 +++++++++--------- .../notification_settings_page.dart | 29 ++++ .../settings/presentation/screens.dart | 1 + .../presentation/settings_screen.dart | 9 +- .../user_events/data/sign_up_status.dart | 5 +- .../user_events/data/sign_up_status.g.dart | 2 +- .../presentation/sign_up_button.dart | 15 +- .../sign_up_button_controller.dart | 6 + .../sign_up_button_controller.g.dart | 2 +- lib/l10n/app_en.arb | 21 ++- lib/l10n/app_es.arb | 21 ++- lib/l10n/generated/app_localizations.dart | 54 +++++++ lib/l10n/generated/app_localizations_en.dart | 27 ++++ lib/l10n/generated/app_localizations_es.dart | 29 ++++ 29 files changed, 604 insertions(+), 140 deletions(-) create mode 100644 lib/core/common/widgets/inputs/any_step_switch_input.dart create mode 100644 lib/core/features/settings/presentation/notification_settings_page.dart diff --git a/lib/core/app.dart b/lib/core/app.dart index 4df875e..8eba249 100644 --- a/lib/core/app.dart +++ b/lib/core/app.dart @@ -39,8 +39,8 @@ class AnyStepApp extends ConsumerWidget { highContrastDarkTheme: AnyStepTheme.highContrastDarkTheme, themeMode: themeMode.hasValue ? themeMode.value : ThemeMode.system, locale: localeAsync.hasValue ? localeAsync.value : null, - builder: - (context, child) => AppStartupWidget(onLoaded: (context) => child ?? const SizedBox()), + builder: (context, child) => + AppStartupWidget(onLoaded: (context) => child ?? const SizedBox()), ); } } diff --git a/lib/core/common/widgets/inputs/any_step_date_time_picker.dart b/lib/core/common/widgets/inputs/any_step_date_time_picker.dart index ba5b427..75c7464 100644 --- a/lib/core/common/widgets/inputs/any_step_date_time_picker.dart +++ b/lib/core/common/widgets/inputs/any_step_date_time_picker.dart @@ -14,6 +14,9 @@ class AnyStepDateTimePicker extends StatelessWidget { this.hintText, this.validator, this.format, + this.useDefaultInitialValue = true, + this.firstDate, + this.lastDate, }); final String name; @@ -24,10 +27,15 @@ class AnyStepDateTimePicker extends StatelessWidget { final String? hintText; final FormFieldValidator? validator; final DateFormat? format; + final bool useDefaultInitialValue; + final DateTime? firstDate; + final DateTime? lastDate; @override Widget build(BuildContext context) { final primary = Theme.of(context).colorScheme.primary; + final labelColor = Theme.of(context).colorScheme.onSurface.withAlpha(153); + final hintColor = Theme.of(context).colorScheme.onSurface.withAlpha(128); return Padding( padding: const EdgeInsets.symmetric(vertical: AnyStepSpacing.sm4), child: FormBuilderDateTimePicker( @@ -37,6 +45,9 @@ class AnyStepDateTimePicker extends StatelessWidget { decoration: InputDecoration( labelText: labelText, hintText: hintText, + labelStyle: TextStyle(color: labelColor), + floatingLabelStyle: TextStyle(color: labelColor), + hintStyle: TextStyle(color: hintColor), border: OutlineInputBorder( borderRadius: const BorderRadius.all(Radius.circular(AnyStepSpacing.md16)), ), @@ -49,7 +60,11 @@ class AnyStepDateTimePicker extends StatelessWidget { borderSide: BorderSide(color: primary, width: 2), ), ), - initialValue: initialValue ?? DateTime.now().add(const Duration(hours: 1)), + initialValue: + initialValue ?? + (useDefaultInitialValue ? DateTime.now().add(const Duration(hours: 1)) : null), + firstDate: firstDate, + lastDate: lastDate, inputType: InputType.both, format: format ?? DateFormat('MM/dd/yy, hh:mm a'), locale: Localizations.localeOf(context), diff --git a/lib/core/common/widgets/inputs/any_step_switch_input.dart b/lib/core/common/widgets/inputs/any_step_switch_input.dart new file mode 100644 index 0000000..02825bf --- /dev/null +++ b/lib/core/common/widgets/inputs/any_step_switch_input.dart @@ -0,0 +1,78 @@ +import 'package:anystep/core/common/constants/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; + +class AnyStepSwitchInput extends StatelessWidget { + const AnyStepSwitchInput({ + super.key, + required this.name, + required this.label, + this.helpText, + this.initialValue = true, + this.onChanged, + }); + + final String name; + final String label; + final String? helpText; + final bool initialValue; + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return FormBuilderField( + name: name, + initialValue: initialValue, + onChanged: onChanged, + builder: (field) { + final value = field.value ?? initialValue; + return Padding( + padding: const EdgeInsets.symmetric(vertical: AnyStepSpacing.sm4), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AnyStepSpacing.md16), + border: Border.all(color: theme.colorScheme.primary, width: 1.5), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(AnyStepSpacing.md16), + onTap: () => field.didChange(!value), + child: Padding( + padding: const EdgeInsets.all(AnyStepSpacing.md16), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.bodyLarge), + if (helpText?.isNotEmpty ?? false) ...[ + const SizedBox(height: AnyStepSpacing.sm2), + Text( + helpText!, + style: theme.textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withAlpha(153), + ), + ), + ], + ], + ), + ), + Switch.adaptive( + activeTrackColor: theme.colorScheme.primary, + value: value, + onChanged: field.didChange, + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/core/common/widgets/inputs/any_step_text_field.dart b/lib/core/common/widgets/inputs/any_step_text_field.dart index 3c43697..19ad482 100644 --- a/lib/core/common/widgets/inputs/any_step_text_field.dart +++ b/lib/core/common/widgets/inputs/any_step_text_field.dart @@ -93,6 +93,8 @@ class _AnyStepTextFieldState extends State with SingleTickerPr @override Widget build(BuildContext context) { final primary = Theme.of(context).colorScheme.primary; + final labelColor = Theme.of(context).colorScheme.onSurface.withAlpha(153); + final hintColor = Theme.of(context).colorScheme.onSurface.withAlpha(128); final showMultiLine = (widget.maxLines > 1 || widget.expandedLines > 1); return Padding( padding: const EdgeInsets.symmetric(vertical: AnyStepSpacing.sm4), @@ -116,6 +118,9 @@ class _AnyStepTextFieldState extends State with SingleTickerPr decoration: InputDecoration( labelText: widget.labelText, hintText: widget.hintText, + labelStyle: TextStyle(color: labelColor), + floatingLabelStyle: TextStyle(color: labelColor), + hintStyle: TextStyle(color: hintColor), alignLabelWithHint: showMultiLine, border: OutlineInputBorder( borderRadius: const BorderRadius.all(Radius.circular(AnyStepSpacing.md16)), diff --git a/lib/core/common/widgets/inputs/inputs.dart b/lib/core/common/widgets/inputs/inputs.dart index 99a4643..d5b3579 100644 --- a/lib/core/common/widgets/inputs/inputs.dart +++ b/lib/core/common/widgets/inputs/inputs.dart @@ -1,4 +1,5 @@ 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 'image_upload_widget.dart'; diff --git a/lib/core/config/router/router.g.dart b/lib/core/config/router/router.g.dart index 09293fe..87f2bfb 100644 --- a/lib/core/config/router/router.g.dart +++ b/lib/core/config/router/router.g.dart @@ -48,4 +48,4 @@ final class RouterProvider } } -String _$routerHash() => r'86ff877f97583deecc3c046349de4464e93425ac'; +String _$routerHash() => r'6cf89ba87685abc4df6ecd6dac9bbd572d701915'; diff --git a/lib/core/config/router/routes.dart b/lib/core/config/router/routes.dart index 4e6eb1d..0a7c16f 100644 --- a/lib/core/config/router/routes.dart +++ b/lib/core/config/router/routes.dart @@ -225,4 +225,9 @@ final routes = [ name: ProfileScreen.name, builder: (context, state) => const ProfileScreen(), ), + GoRoute( + path: NotificationSettingsPage.path, + name: NotificationSettingsPage.name, + builder: (context, state) => const NotificationSettingsPage(), + ), ]; diff --git a/lib/core/config/theme/theme.dart b/lib/core/config/theme/theme.dart index da96fdd..9f95da0 100644 --- a/lib/core/config/theme/theme.dart +++ b/lib/core/config/theme/theme.dart @@ -4,11 +4,31 @@ import 'package:flutter/material.dart'; import 'text_styles.dart'; class AnyStepTheme { + static const double _textScaleFactor = 0.94; + static const double _textHeightFactor = 0.95; + + static TextTheme _tightTextTheme(Color textColor) { + return AnyStepTextStyles.textTheme + .apply( + fontSizeFactor: _textScaleFactor, + heightFactor: _textHeightFactor, + bodyColor: textColor, + displayColor: textColor, + ) + .copyWith( + displayLarge: AnyStepTextStyles.displayLarge.copyWith(color: textColor), + bodyLarge: AnyStepTextStyles.bodyLarge.copyWith(color: textColor), + bodyMedium: AnyStepTextStyles.bodyMedium.copyWith(color: textColor), + ); + } + static final lightTheme = ThemeData( useMaterial3: true, brightness: Brightness.light, fontFamily: '.SF Pro Text', fontFamilyFallback: const ['.SF Pro Display'], + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, colorScheme: ColorScheme.light( primary: AnyStepColors.blueBright, onPrimaryContainer: AnyStepColors.navyDark, @@ -18,11 +38,7 @@ class AnyStepTheme { surface: AnyStepColors.white, error: AnyStepColors.error, ), - textTheme: TextTheme( - displayLarge: AnyStepTextStyles.displayLarge.copyWith(color: AnyStepColors.grayDark), - bodyLarge: AnyStepTextStyles.bodyLarge.copyWith(color: AnyStepColors.grayDark), - bodyMedium: AnyStepTextStyles.bodyMedium.copyWith(color: AnyStepColors.grayDark), - ), + textTheme: _tightTextTheme(AnyStepColors.grayDark), cardTheme: CardThemeData( elevation: AnyStepSpacing.sm2, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AnyStepSpacing.md12)), @@ -40,15 +56,15 @@ class AnyStepTheme { brightness: Brightness.dark, fontFamily: '.SF Pro Text', fontFamilyFallback: const ['.SF Pro Display'], + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, colorScheme: ColorScheme.fromSeed( seedColor: AnyStepColors.blueBright, brightness: Brightness.dark, error: AnyStepColors.errorDark, onError: AnyStepColors.white, ), - textTheme: TextTheme( - displayLarge: AnyStepTextStyles.displayLarge.copyWith(color: AnyStepColors.white), - bodyLarge: AnyStepTextStyles.bodyLarge.copyWith(color: AnyStepColors.white), + textTheme: _tightTextTheme(AnyStepColors.white).copyWith( bodyMedium: AnyStepTextStyles.bodyMedium.copyWith( color: AnyStepColors.lightTertiaryContainer, ), @@ -70,6 +86,8 @@ class AnyStepTheme { brightness: Brightness.light, fontFamily: '.SF Pro Text', fontFamilyFallback: const ['.SF Pro Display'], + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, colorScheme: ColorScheme.highContrastLight( primary: AnyStepColors.blueBright, onPrimaryContainer: AnyStepColors.navyDark, @@ -79,11 +97,7 @@ class AnyStepTheme { surface: AnyStepColors.white, error: AnyStepColors.error, ), - textTheme: TextTheme( - displayLarge: AnyStepTextStyles.displayLarge.copyWith(color: AnyStepColors.grayDark), - bodyLarge: AnyStepTextStyles.bodyLarge.copyWith(color: AnyStepColors.grayDark), - bodyMedium: AnyStepTextStyles.bodyMedium.copyWith(color: AnyStepColors.grayDark), - ), + textTheme: _tightTextTheme(AnyStepColors.grayDark), cardTheme: CardThemeData( elevation: AnyStepSpacing.sm2, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AnyStepSpacing.md12)), @@ -101,6 +115,8 @@ class AnyStepTheme { brightness: Brightness.dark, fontFamily: '.SF Pro Text', fontFamilyFallback: const ['.SF Pro Display'], + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, colorScheme: ColorScheme.fromSeed( seedColor: AnyStepColors.blueBright, contrastLevel: 1.0, @@ -108,9 +124,7 @@ class AnyStepTheme { error: AnyStepColors.errorDark, onError: AnyStepColors.white, ), - textTheme: TextTheme( - displayLarge: AnyStepTextStyles.displayLarge.copyWith(color: AnyStepColors.white), - bodyLarge: AnyStepTextStyles.bodyLarge.copyWith(color: AnyStepColors.white), + textTheme: _tightTextTheme(AnyStepColors.white).copyWith( bodyMedium: AnyStepTextStyles.bodyMedium.copyWith( color: AnyStepColors.lightTertiaryContainer, ), diff --git a/lib/core/features/events/domain/event.dart b/lib/core/features/events/domain/event.dart index db575bf..03d3b8a 100644 --- a/lib/core/features/events/domain/event.dart +++ b/lib/core/features/events/domain/event.dart @@ -23,7 +23,11 @@ abstract class EventModel with _$EventModel { @JsonKey(name: "image_url") String? imageUrl, @JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt, @Default(true) bool active, - @JsonKey(name: "is_volunteer_eligible") @Default(false) bool isVolunteerEligible, + @JsonKey(name: "is_volunteer_eligible") @Default(true) bool isVolunteerEligible, + @JsonKey(name: "max_hours") int? maxHours, + @JsonKey(name: "max_volunteers") int? maxVolunteers, + @JsonKey(name: "registration_deadline") DateTime? registrationDeadline, + @JsonKey(name: "external_link") String? externalLink, }) = _EventModel; factory EventModel.fromJson(Map json) => _$EventModelFromJson(json); diff --git a/lib/core/features/events/domain/event.freezed.dart b/lib/core/features/events/domain/event.freezed.dart index 1e8166f..0bb3501 100644 --- a/lib/core/features/events/domain/event.freezed.dart +++ b/lib/core/features/events/domain/event.freezed.dart @@ -16,7 +16,7 @@ T _$identity(T value) => value; mixin _$EventModel { @JsonKey(includeIfNull: false) int? get id; String get name;// EventTemplateModel? template, -@JsonKey(name: "start_time") DateTime get startTime;@JsonKey(name: "end_time") DateTime get endTime;@JsonKey(name: "address") int? get addressId;@JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? get address; List? get tags; String? get description;@JsonKey(name: "image_url") String? get imageUrl;@JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? get createdAt; bool get active;@JsonKey(name: "is_volunteer_eligible") bool get isVolunteerEligible; +@JsonKey(name: "start_time") DateTime get startTime;@JsonKey(name: "end_time") DateTime get endTime;@JsonKey(name: "address") int? get addressId;@JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? get address; List? get tags; String? get description;@JsonKey(name: "image_url") String? get imageUrl;@JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? get createdAt; bool get active;@JsonKey(name: "is_volunteer_eligible") bool get isVolunteerEligible;@JsonKey(name: "max_hours") int? get maxHours;@JsonKey(name: "max_volunteers") int? get maxVolunteers;@JsonKey(name: "registration_deadline") DateTime? get registrationDeadline;@JsonKey(name: "external_link") String? get externalLink; /// Create a copy of EventModel /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -29,16 +29,16 @@ $EventModelCopyWith get copyWith => _$EventModelCopyWithImpl Object.hash(runtimeType,id,name,startTime,endTime,addressId,address,const DeepCollectionEquality().hash(tags),description,imageUrl,createdAt,active,isVolunteerEligible); +int get hashCode => Object.hash(runtimeType,id,name,startTime,endTime,addressId,address,const DeepCollectionEquality().hash(tags),description,imageUrl,createdAt,active,isVolunteerEligible,maxHours,maxVolunteers,registrationDeadline,externalLink); @override String toString() { - return 'EventModel(id: $id, name: $name, startTime: $startTime, endTime: $endTime, addressId: $addressId, address: $address, tags: $tags, description: $description, imageUrl: $imageUrl, createdAt: $createdAt, active: $active, isVolunteerEligible: $isVolunteerEligible)'; + return 'EventModel(id: $id, name: $name, startTime: $startTime, endTime: $endTime, addressId: $addressId, address: $address, tags: $tags, description: $description, imageUrl: $imageUrl, createdAt: $createdAt, active: $active, isVolunteerEligible: $isVolunteerEligible, maxHours: $maxHours, maxVolunteers: $maxVolunteers, registrationDeadline: $registrationDeadline, externalLink: $externalLink)'; } @@ -49,7 +49,7 @@ abstract mixin class $EventModelCopyWith<$Res> { factory $EventModelCopyWith(EventModel value, $Res Function(EventModel) _then) = _$EventModelCopyWithImpl; @useResult $Res call({ -@JsonKey(includeIfNull: false) int? id, String name,@JsonKey(name: "start_time") DateTime startTime,@JsonKey(name: "end_time") DateTime endTime,@JsonKey(name: "address") int? addressId,@JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address, List? tags, String? description,@JsonKey(name: "image_url") String? imageUrl,@JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt, bool active,@JsonKey(name: "is_volunteer_eligible") bool isVolunteerEligible +@JsonKey(includeIfNull: false) int? id, String name,@JsonKey(name: "start_time") DateTime startTime,@JsonKey(name: "end_time") DateTime endTime,@JsonKey(name: "address") int? addressId,@JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address, List? tags, String? description,@JsonKey(name: "image_url") String? imageUrl,@JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt, bool active,@JsonKey(name: "is_volunteer_eligible") bool isVolunteerEligible,@JsonKey(name: "max_hours") int? maxHours,@JsonKey(name: "max_volunteers") int? maxVolunteers,@JsonKey(name: "registration_deadline") DateTime? registrationDeadline,@JsonKey(name: "external_link") String? externalLink }); @@ -66,7 +66,7 @@ class _$EventModelCopyWithImpl<$Res> /// Create a copy of EventModel /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? name = null,Object? startTime = null,Object? endTime = null,Object? addressId = freezed,Object? address = freezed,Object? tags = freezed,Object? description = freezed,Object? imageUrl = freezed,Object? createdAt = freezed,Object? active = null,Object? isVolunteerEligible = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? name = null,Object? startTime = null,Object? endTime = null,Object? addressId = freezed,Object? address = freezed,Object? tags = freezed,Object? description = freezed,Object? imageUrl = freezed,Object? createdAt = freezed,Object? active = null,Object? isVolunteerEligible = null,Object? maxHours = freezed,Object? maxVolunteers = freezed,Object? registrationDeadline = freezed,Object? externalLink = freezed,}) { return _then(_self.copyWith( id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as int?,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable @@ -80,7 +80,11 @@ as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: as String?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as DateTime?,active: null == active ? _self.active : active // ignore: cast_nullable_to_non_nullable as bool,isVolunteerEligible: null == isVolunteerEligible ? _self.isVolunteerEligible : isVolunteerEligible // ignore: cast_nullable_to_non_nullable -as bool, +as bool,maxHours: freezed == maxHours ? _self.maxHours : maxHours // ignore: cast_nullable_to_non_nullable +as int?,maxVolunteers: freezed == maxVolunteers ? _self.maxVolunteers : maxVolunteers // ignore: cast_nullable_to_non_nullable +as int?,registrationDeadline: freezed == registrationDeadline ? _self.registrationDeadline : registrationDeadline // ignore: cast_nullable_to_non_nullable +as DateTime?,externalLink: freezed == externalLink ? _self.externalLink : externalLink // ignore: cast_nullable_to_non_nullable +as String?, )); } /// Create a copy of EventModel @@ -177,10 +181,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(includeIfNull: false) int? id, String name, @JsonKey(name: "start_time") DateTime startTime, @JsonKey(name: "end_time") DateTime endTime, @JsonKey(name: "address") int? addressId, @JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address, List? tags, String? description, @JsonKey(name: "image_url") String? imageUrl, @JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt, bool active, @JsonKey(name: "is_volunteer_eligible") bool isVolunteerEligible)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(includeIfNull: false) int? id, String name, @JsonKey(name: "start_time") DateTime startTime, @JsonKey(name: "end_time") DateTime endTime, @JsonKey(name: "address") int? addressId, @JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address, List? tags, String? description, @JsonKey(name: "image_url") String? imageUrl, @JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt, bool active, @JsonKey(name: "is_volunteer_eligible") bool isVolunteerEligible, @JsonKey(name: "max_hours") int? maxHours, @JsonKey(name: "max_volunteers") int? maxVolunteers, @JsonKey(name: "registration_deadline") DateTime? registrationDeadline, @JsonKey(name: "external_link") String? externalLink)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _EventModel() when $default != null: -return $default(_that.id,_that.name,_that.startTime,_that.endTime,_that.addressId,_that.address,_that.tags,_that.description,_that.imageUrl,_that.createdAt,_that.active,_that.isVolunteerEligible);case _: +return $default(_that.id,_that.name,_that.startTime,_that.endTime,_that.addressId,_that.address,_that.tags,_that.description,_that.imageUrl,_that.createdAt,_that.active,_that.isVolunteerEligible,_that.maxHours,_that.maxVolunteers,_that.registrationDeadline,_that.externalLink);case _: return orElse(); } @@ -198,10 +202,10 @@ return $default(_that.id,_that.name,_that.startTime,_that.endTime,_that.addressI /// } /// ``` -@optionalTypeArgs TResult when(TResult Function(@JsonKey(includeIfNull: false) int? id, String name, @JsonKey(name: "start_time") DateTime startTime, @JsonKey(name: "end_time") DateTime endTime, @JsonKey(name: "address") int? addressId, @JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address, List? tags, String? description, @JsonKey(name: "image_url") String? imageUrl, @JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt, bool active, @JsonKey(name: "is_volunteer_eligible") bool isVolunteerEligible) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function(@JsonKey(includeIfNull: false) int? id, String name, @JsonKey(name: "start_time") DateTime startTime, @JsonKey(name: "end_time") DateTime endTime, @JsonKey(name: "address") int? addressId, @JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address, List? tags, String? description, @JsonKey(name: "image_url") String? imageUrl, @JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt, bool active, @JsonKey(name: "is_volunteer_eligible") bool isVolunteerEligible, @JsonKey(name: "max_hours") int? maxHours, @JsonKey(name: "max_volunteers") int? maxVolunteers, @JsonKey(name: "registration_deadline") DateTime? registrationDeadline, @JsonKey(name: "external_link") String? externalLink) $default,) {final _that = this; switch (_that) { case _EventModel(): -return $default(_that.id,_that.name,_that.startTime,_that.endTime,_that.addressId,_that.address,_that.tags,_that.description,_that.imageUrl,_that.createdAt,_that.active,_that.isVolunteerEligible);case _: +return $default(_that.id,_that.name,_that.startTime,_that.endTime,_that.addressId,_that.address,_that.tags,_that.description,_that.imageUrl,_that.createdAt,_that.active,_that.isVolunteerEligible,_that.maxHours,_that.maxVolunteers,_that.registrationDeadline,_that.externalLink);case _: throw StateError('Unexpected subclass'); } @@ -218,10 +222,10 @@ return $default(_that.id,_that.name,_that.startTime,_that.endTime,_that.addressI /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(includeIfNull: false) int? id, String name, @JsonKey(name: "start_time") DateTime startTime, @JsonKey(name: "end_time") DateTime endTime, @JsonKey(name: "address") int? addressId, @JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address, List? tags, String? description, @JsonKey(name: "image_url") String? imageUrl, @JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt, bool active, @JsonKey(name: "is_volunteer_eligible") bool isVolunteerEligible)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(includeIfNull: false) int? id, String name, @JsonKey(name: "start_time") DateTime startTime, @JsonKey(name: "end_time") DateTime endTime, @JsonKey(name: "address") int? addressId, @JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address, List? tags, String? description, @JsonKey(name: "image_url") String? imageUrl, @JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt, bool active, @JsonKey(name: "is_volunteer_eligible") bool isVolunteerEligible, @JsonKey(name: "max_hours") int? maxHours, @JsonKey(name: "max_volunteers") int? maxVolunteers, @JsonKey(name: "registration_deadline") DateTime? registrationDeadline, @JsonKey(name: "external_link") String? externalLink)? $default,) {final _that = this; switch (_that) { case _EventModel() when $default != null: -return $default(_that.id,_that.name,_that.startTime,_that.endTime,_that.addressId,_that.address,_that.tags,_that.description,_that.imageUrl,_that.createdAt,_that.active,_that.isVolunteerEligible);case _: +return $default(_that.id,_that.name,_that.startTime,_that.endTime,_that.addressId,_that.address,_that.tags,_that.description,_that.imageUrl,_that.createdAt,_that.active,_that.isVolunteerEligible,_that.maxHours,_that.maxVolunteers,_that.registrationDeadline,_that.externalLink);case _: return null; } @@ -233,7 +237,7 @@ return $default(_that.id,_that.name,_that.startTime,_that.endTime,_that.addressI @JsonSerializable() class _EventModel implements EventModel { - const _EventModel({@JsonKey(includeIfNull: false) this.id, required this.name, @JsonKey(name: "start_time") required this.startTime, @JsonKey(name: "end_time") required this.endTime, @JsonKey(name: "address") this.addressId, @JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) this.address, final List? tags, this.description, @JsonKey(name: "image_url") this.imageUrl, @JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") this.createdAt, this.active = true, @JsonKey(name: "is_volunteer_eligible") this.isVolunteerEligible = false}): _tags = tags; + const _EventModel({@JsonKey(includeIfNull: false) this.id, required this.name, @JsonKey(name: "start_time") required this.startTime, @JsonKey(name: "end_time") required this.endTime, @JsonKey(name: "address") this.addressId, @JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) this.address, final List? tags, this.description, @JsonKey(name: "image_url") this.imageUrl, @JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") this.createdAt, this.active = true, @JsonKey(name: "is_volunteer_eligible") this.isVolunteerEligible = true, @JsonKey(name: "max_hours") this.maxHours, @JsonKey(name: "max_volunteers") this.maxVolunteers, @JsonKey(name: "registration_deadline") this.registrationDeadline, @JsonKey(name: "external_link") this.externalLink}): _tags = tags; factory _EventModel.fromJson(Map json) => _$EventModelFromJson(json); @override@JsonKey(includeIfNull: false) final int? id; @@ -257,6 +261,10 @@ class _EventModel implements EventModel { @override@JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") final DateTime? createdAt; @override@JsonKey() final bool active; @override@JsonKey(name: "is_volunteer_eligible") final bool isVolunteerEligible; +@override@JsonKey(name: "max_hours") final int? maxHours; +@override@JsonKey(name: "max_volunteers") final int? maxVolunteers; +@override@JsonKey(name: "registration_deadline") final DateTime? registrationDeadline; +@override@JsonKey(name: "external_link") final String? externalLink; /// Create a copy of EventModel /// with the given fields replaced by the non-null parameter values. @@ -271,16 +279,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _EventModel&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.startTime, startTime) || other.startTime == startTime)&&(identical(other.endTime, endTime) || other.endTime == endTime)&&(identical(other.addressId, addressId) || other.addressId == addressId)&&(identical(other.address, address) || other.address == address)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.description, description) || other.description == description)&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.active, active) || other.active == active)&&(identical(other.isVolunteerEligible, isVolunteerEligible) || other.isVolunteerEligible == isVolunteerEligible)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EventModel&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.startTime, startTime) || other.startTime == startTime)&&(identical(other.endTime, endTime) || other.endTime == endTime)&&(identical(other.addressId, addressId) || other.addressId == addressId)&&(identical(other.address, address) || other.address == address)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.description, description) || other.description == description)&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.active, active) || other.active == active)&&(identical(other.isVolunteerEligible, isVolunteerEligible) || other.isVolunteerEligible == isVolunteerEligible)&&(identical(other.maxHours, maxHours) || other.maxHours == maxHours)&&(identical(other.maxVolunteers, maxVolunteers) || other.maxVolunteers == maxVolunteers)&&(identical(other.registrationDeadline, registrationDeadline) || other.registrationDeadline == registrationDeadline)&&(identical(other.externalLink, externalLink) || other.externalLink == externalLink)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,id,name,startTime,endTime,addressId,address,const DeepCollectionEquality().hash(_tags),description,imageUrl,createdAt,active,isVolunteerEligible); +int get hashCode => Object.hash(runtimeType,id,name,startTime,endTime,addressId,address,const DeepCollectionEquality().hash(_tags),description,imageUrl,createdAt,active,isVolunteerEligible,maxHours,maxVolunteers,registrationDeadline,externalLink); @override String toString() { - return 'EventModel(id: $id, name: $name, startTime: $startTime, endTime: $endTime, addressId: $addressId, address: $address, tags: $tags, description: $description, imageUrl: $imageUrl, createdAt: $createdAt, active: $active, isVolunteerEligible: $isVolunteerEligible)'; + return 'EventModel(id: $id, name: $name, startTime: $startTime, endTime: $endTime, addressId: $addressId, address: $address, tags: $tags, description: $description, imageUrl: $imageUrl, createdAt: $createdAt, active: $active, isVolunteerEligible: $isVolunteerEligible, maxHours: $maxHours, maxVolunteers: $maxVolunteers, registrationDeadline: $registrationDeadline, externalLink: $externalLink)'; } @@ -291,7 +299,7 @@ abstract mixin class _$EventModelCopyWith<$Res> implements $EventModelCopyWith<$ factory _$EventModelCopyWith(_EventModel value, $Res Function(_EventModel) _then) = __$EventModelCopyWithImpl; @override @useResult $Res call({ -@JsonKey(includeIfNull: false) int? id, String name,@JsonKey(name: "start_time") DateTime startTime,@JsonKey(name: "end_time") DateTime endTime,@JsonKey(name: "address") int? addressId,@JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address, List? tags, String? description,@JsonKey(name: "image_url") String? imageUrl,@JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt, bool active,@JsonKey(name: "is_volunteer_eligible") bool isVolunteerEligible +@JsonKey(includeIfNull: false) int? id, String name,@JsonKey(name: "start_time") DateTime startTime,@JsonKey(name: "end_time") DateTime endTime,@JsonKey(name: "address") int? addressId,@JsonKey(name: "address_model", includeToJson: false, includeFromJson: true) AddressModel? address, List? tags, String? description,@JsonKey(name: "image_url") String? imageUrl,@JsonKey(includeToJson: false, includeFromJson: true, name: "created_at") DateTime? createdAt, bool active,@JsonKey(name: "is_volunteer_eligible") bool isVolunteerEligible,@JsonKey(name: "max_hours") int? maxHours,@JsonKey(name: "max_volunteers") int? maxVolunteers,@JsonKey(name: "registration_deadline") DateTime? registrationDeadline,@JsonKey(name: "external_link") String? externalLink }); @@ -308,7 +316,7 @@ class __$EventModelCopyWithImpl<$Res> /// Create a copy of EventModel /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = freezed,Object? name = null,Object? startTime = null,Object? endTime = null,Object? addressId = freezed,Object? address = freezed,Object? tags = freezed,Object? description = freezed,Object? imageUrl = freezed,Object? createdAt = freezed,Object? active = null,Object? isVolunteerEligible = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = freezed,Object? name = null,Object? startTime = null,Object? endTime = null,Object? addressId = freezed,Object? address = freezed,Object? tags = freezed,Object? description = freezed,Object? imageUrl = freezed,Object? createdAt = freezed,Object? active = null,Object? isVolunteerEligible = null,Object? maxHours = freezed,Object? maxVolunteers = freezed,Object? registrationDeadline = freezed,Object? externalLink = freezed,}) { return _then(_EventModel( id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as int?,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable @@ -322,7 +330,11 @@ as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: as String?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as DateTime?,active: null == active ? _self.active : active // ignore: cast_nullable_to_non_nullable as bool,isVolunteerEligible: null == isVolunteerEligible ? _self.isVolunteerEligible : isVolunteerEligible // ignore: cast_nullable_to_non_nullable -as bool, +as bool,maxHours: freezed == maxHours ? _self.maxHours : maxHours // ignore: cast_nullable_to_non_nullable +as int?,maxVolunteers: freezed == maxVolunteers ? _self.maxVolunteers : maxVolunteers // ignore: cast_nullable_to_non_nullable +as int?,registrationDeadline: freezed == registrationDeadline ? _self.registrationDeadline : registrationDeadline // ignore: cast_nullable_to_non_nullable +as DateTime?,externalLink: freezed == externalLink ? _self.externalLink : externalLink // ignore: cast_nullable_to_non_nullable +as String?, )); } diff --git a/lib/core/features/events/domain/event.g.dart b/lib/core/features/events/domain/event.g.dart index fc9b204..0848f3f 100644 --- a/lib/core/features/events/domain/event.g.dart +++ b/lib/core/features/events/domain/event.g.dart @@ -22,7 +22,13 @@ _EventModel _$EventModelFromJson(Map json) => _EventModel( ? null : DateTime.parse(json['created_at'] as String), active: json['active'] as bool? ?? true, - isVolunteerEligible: json['is_volunteer_eligible'] as bool? ?? false, + isVolunteerEligible: json['is_volunteer_eligible'] as bool? ?? true, + maxHours: (json['max_hours'] as num?)?.toInt(), + maxVolunteers: (json['max_volunteers'] as num?)?.toInt(), + registrationDeadline: json['registration_deadline'] == null + ? null + : DateTime.parse(json['registration_deadline'] as String), + externalLink: json['external_link'] as String?, ); Map _$EventModelToJson(_EventModel instance) => @@ -37,4 +43,8 @@ Map _$EventModelToJson(_EventModel instance) => 'image_url': instance.imageUrl, 'active': instance.active, 'is_volunteer_eligible': instance.isVolunteerEligible, + 'max_hours': instance.maxHours, + 'max_volunteers': instance.maxVolunteers, + 'registration_deadline': instance.registrationDeadline?.toIso8601String(), + 'external_link': instance.externalLink, }; 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 f61a349..6e861b6 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 @@ -23,6 +23,7 @@ class EventDetailForm extends ConsumerStatefulWidget { class _EventDetailFormState extends ConsumerState { final formKey = GlobalKey(); XFile? _imageFile; + DateTime? _startTime; void _onSubmit() async { if (formKey.currentState?.saveAndValidate() ?? false) { @@ -44,6 +45,7 @@ class _EventDetailFormState extends ConsumerState { final now = DateTime.now().toLocal(); final tomorrow = DateTime(now.year, now.month, now.day + 1, 0, 0); final loc = AppLocalizations.of(context); + _startTime ??= widget.event?.startTime.toLocal(); return PopScope( canPop: !state.isLoading, child: Padding( @@ -80,6 +82,13 @@ class _EventDetailFormState extends ConsumerState { expandedLines: 10, initialValue: widget.event?.description, ), + AnyStepSwitchInput( + name: 'isVolunteerEligible', + label: loc.volunteerEventLabel, + helpText: loc.volunteerEventHelp, + initialValue: widget.event?.isVolunteerEligible ?? true, + ), + const SizedBox(height: AnyStepSpacing.sm4), Row( children: [ Flexible( @@ -89,15 +98,16 @@ class _EventDetailFormState extends ConsumerState { initialValue: widget.event?.startTime.toLocal() ?? DateTime(tomorrow.year, tomorrow.month, tomorrow.day, 8, 0), - validator: FormBuilderValidators.compose([ - FormBuilderValidators.required(), - (val) { - if (val != null && (val.toLocal().isBefore(now))) { - return 'Start time has passed'; - } - return null; - }, - ]), + onChanged: (val) { + setState(() => _startTime = val); + final endField = formKey.currentState?.fields['endTime']; + if (val != null && + endField?.value != null && + (endField!.value as DateTime).isBefore(val)) { + endField.didChange(val.add(const Duration(hours: 1))); + } + }, + validator: FormBuilderValidators.required(), ), ), const SizedBox(width: AnyStepSpacing.sm2), @@ -108,12 +118,13 @@ class _EventDetailFormState extends ConsumerState { initialValue: widget.event?.endTime.toLocal() ?? DateTime(tomorrow.year, tomorrow.month, tomorrow.day, 9, 0), + firstDate: _startTime, validator: FormBuilderValidators.compose([ FormBuilderValidators.required(), (val) { if (val != null && - widget.event != null && - val.isBefore(widget.event!.startTime)) { + _startTime != null && + val.isBefore(_startTime!)) { return 'End time must be after start time'; } return null; @@ -123,6 +134,8 @@ class _EventDetailFormState extends ConsumerState { ), ], ), + + const SizedBox(height: AnyStepSpacing.sm4), AnyStepTextField( name: 'street', initialValue: widget.event?.address?.street, @@ -168,6 +181,58 @@ class _EventDetailFormState extends ConsumerState { ), ], ), + Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(vertical: AnyStepSpacing.sm4), + childrenPadding: const EdgeInsets.only(bottom: AnyStepSpacing.sm4), + title: Text(loc.advancedOptions), + children: [ + Row( + children: [ + Flexible( + flex: 2, + child: AnyStepTextField( + name: 'maxVolunteers', + initialValue: widget.event?.maxVolunteers?.toString(), + labelText: loc.maxVolunteersOptional, + keyboardType: TextInputType.number, + validator: FormBuilderValidators.integer(checkNullOrEmpty: false), + ), + ), + const SizedBox(width: AnyStepSpacing.sm2), + Flexible( + flex: 3, + child: AnyStepDateTimePicker( + name: 'registrationDeadline', + labelText: loc.registrationDeadlineOptional, + initialValue: widget.event?.registrationDeadline?.toLocal(), + useDefaultInitialValue: false, + lastDate: _startTime, + validator: FormBuilderValidators.compose([ + (val) { + if (val != null && + _startTime != null && + val.isAfter(_startTime!)) { + return 'Deadline must be before event start time'; + } + return null; + }, + ]), + ), + ), + ], + ), + AnyStepTextField( + name: 'externalLink', + initialValue: widget.event?.externalLink, + labelText: loc.externalLinkOptional, + keyboardType: TextInputType.url, + validator: FormBuilderValidators.url(checkNullOrEmpty: false), + ), + ], + ), + ), ], ), ), diff --git a/lib/core/features/events/presentation/event_detail/event_detail_form_controller.dart b/lib/core/features/events/presentation/event_detail/event_detail_form_controller.dart index b98127c..7928c34 100644 --- a/lib/core/features/events/presentation/event_detail/event_detail_form_controller.dart +++ b/lib/core/features/events/presentation/event_detail/event_detail_form_controller.dart @@ -18,6 +18,16 @@ class EventDetailFormController extends _$EventDetailFormController { Future createOrUpdateEvent(Map values, {XFile? image}) async { state = state.copyWith(isLoading: true, error: null); try { + final maxVolunteersRaw = values['maxVolunteers']; + final maxVolunteers = (maxVolunteersRaw == null || maxVolunteersRaw.toString().trim().isEmpty) + ? null + : int.tryParse(maxVolunteersRaw.toString()); + final externalLinkRaw = values['externalLink']; + final externalLink = + (externalLinkRaw == null || externalLinkRaw.toString().trim().isEmpty) + ? null + : externalLinkRaw.toString(); + final event = EventModel( id: state.eventId, name: values['name']!, @@ -34,6 +44,10 @@ class EventDetailFormController extends _$EventDetailFormController { isUserAddress: false, ), description: values['description'], + isVolunteerEligible: values['isVolunteerEligible'] ?? true, + maxVolunteers: maxVolunteers, + registrationDeadline: (values['registrationDeadline'] as DateTime?)?.toUtc(), + externalLink: externalLink, ); String? imageUrl; diff --git a/lib/core/features/events/presentation/event_detail/event_detail_form_controller.g.dart b/lib/core/features/events/presentation/event_detail/event_detail_form_controller.g.dart index 4a896ef..acd6aa5 100644 --- a/lib/core/features/events/presentation/event_detail/event_detail_form_controller.g.dart +++ b/lib/core/features/events/presentation/event_detail/event_detail_form_controller.g.dart @@ -60,7 +60,7 @@ final class EventDetailFormControllerProvider } String _$eventDetailFormControllerHash() => - r'87a35097a8aecde79341e09761a42a7e5c60dd77'; + r'7a7702d263176f0b26f413df8f9c72989bf89bcd'; final class EventDetailFormControllerFamily extends $Family with diff --git a/lib/core/features/events/presentation/event_detail/event_detail_info.dart b/lib/core/features/events/presentation/event_detail/event_detail_info.dart index bf41a91..69d22d7 100644 --- a/lib/core/features/events/presentation/event_detail/event_detail_info.dart +++ b/lib/core/features/events/presentation/event_detail/event_detail_info.dart @@ -9,6 +9,7 @@ import 'package:anystep/core/features/events/presentation/widgets/event_time_tab import 'package:anystep/core/features/location/utils/launch_map.dart'; import 'package:anystep/l10n/generated/app_localizations.dart'; import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; class EventDetailInfo extends StatelessWidget { const EventDetailInfo({super.key, required this.event}); @@ -146,9 +147,48 @@ class EventDetailInfo extends StatelessWidget { } : null, ), + if (event.externalLink != null && event.externalLink!.trim().isNotEmpty) + _ExternalLinkTile(url: event.externalLink!, label: loc.externalLink), ], ), ), ); } } + +class _ExternalLinkTile extends StatelessWidget { + const _ExternalLinkTile({required this.url, required this.label}); + + final String url; + final String label; + + Uri? _buildUri() { + final trimmed = url.trim(); + if (trimmed.isEmpty) return null; + final parsed = Uri.tryParse(trimmed); + if (parsed == null) return null; + if (parsed.hasScheme) return parsed; + return Uri.tryParse('https://$trimmed'); + } + + @override + Widget build(BuildContext context) { + return ListTile( + leading: const Icon(Icons.open_in_new), + title: Text(label, style: Theme.of(context).textTheme.titleMedium), + subtitle: Text(url), + onTap: () async { + final uri = _buildUri(); + if (uri == null) return; + try { + final launched = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!launched) { + Log.e('Failed to open external link: $uri'); + } + } catch (e) { + Log.e('Error opening external link', e); + } + }, + ); + } +} diff --git a/lib/core/features/events/presentation/widgets/event_time_table.dart b/lib/core/features/events/presentation/widgets/event_time_table.dart index 5ca41e6..38c1024 100644 --- a/lib/core/features/events/presentation/widgets/event_time_table.dart +++ b/lib/core/features/events/presentation/widgets/event_time_table.dart @@ -16,85 +16,93 @@ class EventTimeTable extends StatelessWidget { final isSameDay = event.startTime.isSameDay(event.endTime); final duration = event.startTime.difference(event.endTime).inHours.abs(); final loc = AppLocalizations.of(context); - return Table( - columnWidths: const { - 0: FlexColumnWidth(), - 1: IntrinsicColumnWidth(), - 2: IntrinsicColumnWidth(), - }, + return Column( children: [ - TableRow( + Table( + columnWidths: const { + 0: FlexColumnWidth(), + 1: IntrinsicColumnWidth(), + 2: IntrinsicColumnWidth(), + }, children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: AnyStepSpacing.sm4), - child: Text(loc.start, style: Theme.of(context).textTheme.titleMedium), + TableRow( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: AnyStepSpacing.sm4), + child: Text( + loc.start, + style: Theme.of(context).textTheme.titleMedium, + softWrap: false, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: AnyStepSpacing.sm4, + horizontal: AnyStepSpacing.md12, + ), + child: Text(DateFormat.yMEd().format(event.startTime.toLocal())), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: AnyStepSpacing.sm4), + child: Text( + DateFormat.jm().format(event.startTime.toLocal()), + textAlign: TextAlign.center, + ), + ), + ], ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: AnyStepSpacing.sm4, - horizontal: AnyStepSpacing.md12, - ), - child: Text(DateFormat.yMEd().format(event.startTime.toLocal())), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: AnyStepSpacing.sm4), - child: Text( - DateFormat.jm().format(event.startTime.toLocal()), - textAlign: TextAlign.center, - ), - ), - ], - ), - TableRow( - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: AnyStepSpacing.sm4), - child: Text(loc.end, style: Theme.of(context).textTheme.titleMedium), - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: AnyStepSpacing.sm4, - horizontal: AnyStepSpacing.md12, - ), - child: isSameDay - ? const SizedBox() - : Text(DateFormat.yMEd().format(event.endTime.toLocal())), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: AnyStepSpacing.sm4), - child: Text( - DateFormat.jm().format(event.endTime.toLocal()), - textAlign: TextAlign.center, - ), + TableRow( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: AnyStepSpacing.sm4), + child: Text( + loc.end, + style: Theme.of(context).textTheme.titleMedium, + softWrap: false, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: AnyStepSpacing.sm4, + horizontal: AnyStepSpacing.md12, + ), + child: isSameDay + ? const SizedBox() + : Text(DateFormat.yMEd().format(event.endTime.toLocal())), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: AnyStepSpacing.sm4), + child: Text( + DateFormat.jm().format(event.endTime.toLocal()), + textAlign: TextAlign.center, + ), + ), + ], ), ], ), - if (isSameDay) - TableRow( - children: [ - const SizedBox(), - const SizedBox(), // Empty cell for alignment - event.isVolunteerEligible - ? AnyStepBadge( - child: Text( - loc.hours(duration, duration != 1 ? 's' : ''), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSecondary, - ), - ), - ) - : Padding( - padding: const EdgeInsets.only(top: AnyStepSpacing.sm4), - child: Text( - loc.notEligibleVolunteerHours, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.labelMedium, - ), + Align( + alignment: Alignment.centerRight, + child: event.isVolunteerEligible + ? AnyStepBadge( + child: Text( + loc.hours(duration, duration != 1 ? 's' : ''), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSecondary, ), - ], - ), + ), + ) + : Padding( + padding: const EdgeInsets.only(top: AnyStepSpacing.sm4), + child: Text( + loc.notEligibleVolunteerHours, + textAlign: TextAlign.end, + style: Theme.of(context).textTheme.labelMedium, + ), + ), + ), ], ); } diff --git a/lib/core/features/settings/presentation/notification_settings_page.dart b/lib/core/features/settings/presentation/notification_settings_page.dart new file mode 100644 index 0000000..0624f63 --- /dev/null +++ b/lib/core/features/settings/presentation/notification_settings_page.dart @@ -0,0 +1,29 @@ +import 'package:anystep/core/common/constants/spacing.dart'; +import 'package:anystep/core/common/widgets/widgets.dart'; +import 'package:anystep/core/features/notifications/presentation/event_notifications_tile.dart'; +import 'package:anystep/l10n/generated/app_localizations.dart'; +import 'package:flutter/material.dart'; + +class NotificationSettingsPage extends StatelessWidget { + const NotificationSettingsPage({super.key}); + + static const path = '/settings/notifications'; + static const name = 'notification-settings'; + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + return AnyStepScaffold( + appBar: AnyStepAppBar(title: Text(loc.notificationSettingsTitle)), + body: ListView( + padding: const EdgeInsets.all(AnyStepSpacing.md16), + children: [ + EventNotificationsTile( + title: loc.eventNotificationsTitle, + subtitle: loc.eventNotificationsDescription, + ), + ], + ), + ); + } +} diff --git a/lib/core/features/settings/presentation/screens.dart b/lib/core/features/settings/presentation/screens.dart index 2213cb2..55b3a5c 100644 --- a/lib/core/features/settings/presentation/screens.dart +++ b/lib/core/features/settings/presentation/screens.dart @@ -1 +1,2 @@ export 'settings_screen.dart'; +export 'notification_settings_page.dart'; diff --git a/lib/core/features/settings/presentation/settings_screen.dart b/lib/core/features/settings/presentation/settings_screen.dart index 6d3844f..f61d95c 100644 --- a/lib/core/features/settings/presentation/settings_screen.dart +++ b/lib/core/features/settings/presentation/settings_screen.dart @@ -1,7 +1,6 @@ import 'package:anystep/core/common/constants/spacing.dart'; import 'package:anystep/core/common/widgets/widgets.dart'; import 'package:anystep/core/features/auth/data/auth_repository.dart'; -import 'package:anystep/core/features/notifications/presentation/event_notifications_tile.dart'; import 'package:anystep/core/features/screens.dart'; import 'package:anystep/core/features/settings/presentation/theme_mode_setting.dart'; import 'package:anystep/core/features/settings/presentation/locale_setting.dart'; @@ -34,9 +33,11 @@ class SettingsScreen extends ConsumerWidget { children: [ const ThemeModeSetting(), const LocaleSetting(), - EventNotificationsTile( - title: loc.eventNotificationsTitle, - subtitle: loc.eventNotificationsDescription, + ListTile( + leading: const Icon(Icons.notifications), + title: Text(loc.notificationSettingsTitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push(NotificationSettingsPage.path), ), if (isAuth != null) ...[ ListTile( diff --git a/lib/core/features/user_events/data/sign_up_status.dart b/lib/core/features/user_events/data/sign_up_status.dart index d6d2d0b..d2d5f0a 100644 --- a/lib/core/features/user_events/data/sign_up_status.dart +++ b/lib/core/features/user_events/data/sign_up_status.dart @@ -18,9 +18,12 @@ Future signUpStatus(Ref ref, int eventId) async { ref.watch(getCurrentUserEventsProvider(eventId: eventId).future), ref.watch(getEventProvider(eventId).future), ]); + final now = DateTime.now().toUtc(); + final isPastDeadline = + event.registrationDeadline != null && now.isAfter(event.registrationDeadline!.toUtc()); return SignUpStatus.data( didSignUp: userEvents.totalCount > 0, - canSignUp: event.startTime.isAfter(DateTime.now()), + canSignUp: event.startTime.isAfter(now) && !isPastDeadline, userEvent: userEvents.items.isNotEmpty ? userEvents.items.first : null, ); } catch (e) { diff --git a/lib/core/features/user_events/data/sign_up_status.g.dart b/lib/core/features/user_events/data/sign_up_status.g.dart index f6adaa9..ae54262 100644 --- a/lib/core/features/user_events/data/sign_up_status.g.dart +++ b/lib/core/features/user_events/data/sign_up_status.g.dart @@ -77,7 +77,7 @@ final class SignUpStatusProvider } } -String _$signUpStatusHash() => r'02390b445a29c931ca1f4496e9aec8182ad00f3f'; +String _$signUpStatusHash() => r'980fa0832857e9be9a571999b03a4ab28905580b'; /// Fetches the sign-up status for a specific event. /// It checks if the user can sign up, if they have already signed up, diff --git a/lib/core/features/user_events/presentation/sign_up_button.dart b/lib/core/features/user_events/presentation/sign_up_button.dart index ff08efc..e4267f7 100644 --- a/lib/core/features/user_events/presentation/sign_up_button.dart +++ b/lib/core/features/user_events/presentation/sign_up_button.dart @@ -21,6 +21,9 @@ class SignUpButton extends ConsumerWidget { final signUpStatusAsync = ref.watch(signUpStatusProvider(eventId)); final state = ref.watch(signUpButtonControllerProvider); final loc = AppLocalizations.of(context); + final now = DateTime.now().toUtc(); + final isPastDeadline = + event.registrationDeadline != null && now.isAfter(event.registrationDeadline!.toUtc()); return signUpStatusAsync.maybeWhen( data: (status) => switch (status) { @@ -36,7 +39,7 @@ class SignUpButton extends ConsumerWidget { ? AnyStepColors.white : AnyStepColors.blueBright, // Text/icon color ), - onPressed: state.isLoading || !data.canSignUp + onPressed: state.isLoading || (!data.didSignUp && !data.canSignUp) ? null : data.didSignUp ? () => ref @@ -55,10 +58,12 @@ class SignUpButton extends ConsumerWidget { child: state.isLoading ? const CircularProgressIndicator.adaptive() : Text( - data.canSignUp - ? data.didSignUp - ? loc.cancelSignUp - : loc.signUp + data.didSignUp + ? loc.cancelSignUp + : data.canSignUp + ? loc.signUp + : isPastDeadline + ? loc.registrationClosed : loc.noLongerAvailable, ), ), diff --git a/lib/core/features/user_events/presentation/sign_up_button_controller.dart b/lib/core/features/user_events/presentation/sign_up_button_controller.dart index 08b44a5..1eebe4c 100644 --- a/lib/core/features/user_events/presentation/sign_up_button_controller.dart +++ b/lib/core/features/user_events/presentation/sign_up_button_controller.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:anystep/core/config/posthog/posthog_manager.dart'; import 'package:anystep/core/features/auth/data/auth_repository.dart'; +import 'package:anystep/core/features/events/data/event_repository.dart'; import 'package:anystep/core/features/user_events/data/sign_up_status.dart'; import 'package:anystep/core/features/user_events/data/user_event_repository.dart'; import 'package:anystep/core/features/user_events/domain/user_event.dart'; @@ -17,6 +18,11 @@ class SignUpButtonController extends _$SignUpButtonController { Future signUp({required int eventId}) async { state = const AsyncLoading(); state = await AsyncValue.guard(() async { + final event = await ref.read(getEventProvider(eventId).future); + final now = DateTime.now().toUtc(); + if (event.registrationDeadline != null && now.isAfter(event.registrationDeadline!.toUtc())) { + return; + } final authState = await ref.read(authStateStreamProvider.future); final UserEventModel userEvent = UserEventModel( diff --git a/lib/core/features/user_events/presentation/sign_up_button_controller.g.dart b/lib/core/features/user_events/presentation/sign_up_button_controller.g.dart index e52c65b..407b9ab 100644 --- a/lib/core/features/user_events/presentation/sign_up_button_controller.g.dart +++ b/lib/core/features/user_events/presentation/sign_up_button_controller.g.dart @@ -34,7 +34,7 @@ final class SignUpButtonControllerProvider } String _$signUpButtonControllerHash() => - r'25882fa0bfe82a6884c8811691ee8e5c894b11d2'; + r'30c99a52816b8e666fe817352989f9dab9044f1b'; abstract class _$SignUpButtonController extends $AsyncNotifier { FutureOr build(); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 91889a0..9e66cfe 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -332,6 +332,8 @@ "@onboardingImpactTitle": {"description": "Title for onboarding impact page"}, "onboardingImpactDesc": "Join events and measure the difference you make.", "@onboardingImpactDesc": {"description": "Description for onboarding impact page"}, + "notificationSettingsTitle": "Notification Settings", + "@notificationSettingsTitle": {"description": "Settings label for notifications page and toggle"}, "eventNotificationsTitle": "Event notifications", "@eventNotificationsTitle": {"description": "Settings label for event notification toggle"}, "eventNotificationsDescription": "Notify me when a new event is added.", @@ -381,5 +383,22 @@ "@noUsersFound": {"description": "Empty state when no users are available"} , "enterSearchTerm": "Please enter a search term to find events.", - "@enterSearchTerm": {"description": "Text when search field is empty"} + "@enterSearchTerm": {"description": "Text when search field is empty"}, + "volunteerEventLabel": "Volunteer Event", + "@volunteerEventLabel": {"description": "Label for volunteer eligible event toggle"}, + "volunteerEventHelp": "This event is eligible for volunteer hours", + "@volunteerEventHelp": {"description": "Help text for volunteer eligible event toggle"} + , + "advancedOptions": "Advanced Options", + "@advancedOptions": {"description": "Section title for advanced event form options"}, + "maxVolunteersOptional": "Max Volunteers (optional)", + "@maxVolunteersOptional": {"description": "Label for max volunteers field"}, + "registrationDeadlineOptional": "Registration Deadline (optional)", + "@registrationDeadlineOptional": {"description": "Label for registration deadline field"}, + "externalLinkOptional": "External Link (optional)", + "@externalLinkOptional": {"description": "Label for external link field"}, + "externalLink": "External Link", + "@externalLink": {"description": "Label for external link list tile"}, + "registrationClosed": "Registration closed", + "@registrationClosed": {"description": "Label for sign up button when registration deadline has passed"} } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 1c855f1..bed0b39 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -333,6 +333,8 @@ "@onboardingImpactTitle": {"description": "Título de la página de impacto"}, "onboardingImpactDesc": "Únete a eventos y mide la diferencia que generas.", "@onboardingImpactDesc": {"description": "Descripción de la página de impacto"}, + "notificationSettingsTitle": "Configuración de notificaciones", + "@notificationSettingsTitle": {"description": "Etiqueta de ajustes para la página y el interruptor de notificaciones"}, "eventNotificationsTitle": "Notificaciones de eventos", "@eventNotificationsTitle": {"description": "Etiqueta de ajustes para el interruptor de notificaciones de eventos"}, "eventNotificationsDescription": "Avísame cuando se agregue un nuevo evento.", @@ -383,6 +385,23 @@ "@checkBackLater": {"description": "Texto de ayuda invitando a regresar más tarde"} , "noUsersFound": "No hay usuarios", - "@noUsersFound": {"description": "Estado vacío cuando no hay usuarios"} + "@noUsersFound": {"description": "Estado vacío cuando no hay usuarios"}, + "volunteerEventLabel": "Evento de voluntariado", + "@volunteerEventLabel": {"description": "Etiqueta para el interruptor de evento elegible para voluntariado"}, + "volunteerEventHelp": "Este evento es elegible para horas de voluntariado", + "@volunteerEventHelp": {"description": "Texto de ayuda para el interruptor de evento elegible para voluntariado"} + , + "advancedOptions": "Opciones avanzadas", + "@advancedOptions": {"description": "Título de la sección para opciones avanzadas del evento"}, + "maxVolunteersOptional": "Máximo de voluntarios (opcional)", + "@maxVolunteersOptional": {"description": "Etiqueta para el campo de máximo de voluntarios"}, + "registrationDeadlineOptional": "Fecha límite de registro (opcional)", + "@registrationDeadlineOptional": {"description": "Etiqueta para el campo de fecha límite de registro"}, + "externalLinkOptional": "Enlace externo (opcional)", + "@externalLinkOptional": {"description": "Etiqueta para el campo de enlace externo"}, + "externalLink": "Enlace externo", + "@externalLink": {"description": "Etiqueta para la lista de enlace externo"}, + "registrationClosed": "Registro cerrado", + "@registrationClosed": {"description": "Etiqueta del botón de registro cuando la fecha límite ha pasado"} } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 29eb983..d020740 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -950,6 +950,12 @@ abstract class AppLocalizations { /// **'Join events and measure the difference you make.'** String get onboardingImpactDesc; + /// Settings label for notifications page and toggle + /// + /// In en, this message translates to: + /// **'Notification Settings'** + String get notificationSettingsTitle; + /// Settings label for event notification toggle /// /// In en, this message translates to: @@ -1081,6 +1087,54 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Please enter a search term to find events.'** String get enterSearchTerm; + + /// Label for volunteer eligible event toggle + /// + /// In en, this message translates to: + /// **'Volunteer Event'** + String get volunteerEventLabel; + + /// Help text for volunteer eligible event toggle + /// + /// In en, this message translates to: + /// **'This event is eligible for volunteer hours'** + String get volunteerEventHelp; + + /// Section title for advanced event form options + /// + /// In en, this message translates to: + /// **'Advanced Options'** + String get advancedOptions; + + /// Label for max volunteers field + /// + /// In en, this message translates to: + /// **'Max Volunteers (optional)'** + String get maxVolunteersOptional; + + /// Label for registration deadline field + /// + /// In en, this message translates to: + /// **'Registration Deadline (optional)'** + String get registrationDeadlineOptional; + + /// Label for external link field + /// + /// In en, this message translates to: + /// **'External Link (optional)'** + String get externalLinkOptional; + + /// Label for external link list tile + /// + /// In en, this message translates to: + /// **'External Link'** + String get externalLink; + + /// Label for sign up button when registration deadline has passed + /// + /// In en, this message translates to: + /// **'Registration closed'** + String get registrationClosed; } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index fb21278..66f742b 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -468,6 +468,9 @@ class AppLocalizationsEn extends AppLocalizations { String get onboardingImpactDesc => 'Join events and measure the difference you make.'; + @override + String get notificationSettingsTitle => 'Notification Settings'; + @override String get eventNotificationsTitle => 'Event notifications'; @@ -534,4 +537,28 @@ class AppLocalizationsEn extends AppLocalizations { @override String get enterSearchTerm => 'Please enter a search term to find events.'; + + @override + String get volunteerEventLabel => 'Volunteer Event'; + + @override + String get volunteerEventHelp => 'This event is eligible for volunteer hours'; + + @override + String get advancedOptions => 'Advanced Options'; + + @override + String get maxVolunteersOptional => 'Max Volunteers (optional)'; + + @override + String get registrationDeadlineOptional => 'Registration Deadline (optional)'; + + @override + String get externalLinkOptional => 'External Link (optional)'; + + @override + String get externalLink => 'External Link'; + + @override + String get registrationClosed => 'Registration closed'; } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index fba4596..3543659 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -472,6 +472,9 @@ class AppLocalizationsEs extends AppLocalizations { String get onboardingImpactDesc => 'Únete a eventos y mide la diferencia que generas.'; + @override + String get notificationSettingsTitle => 'Configuración de notificaciones'; + @override String get eventNotificationsTitle => 'Notificaciones de eventos'; @@ -539,4 +542,30 @@ class AppLocalizationsEs extends AppLocalizations { @override String get enterSearchTerm => 'Ingresa un término de búsqueda para encontrar eventos.'; + + @override + String get volunteerEventLabel => 'Evento de voluntariado'; + + @override + String get volunteerEventHelp => + 'Este evento es elegible para horas de voluntariado'; + + @override + String get advancedOptions => 'Opciones avanzadas'; + + @override + String get maxVolunteersOptional => 'Máximo de voluntarios (opcional)'; + + @override + String get registrationDeadlineOptional => + 'Fecha límite de registro (opcional)'; + + @override + String get externalLinkOptional => 'Enlace externo (opcional)'; + + @override + String get externalLink => 'Enlace externo'; + + @override + String get registrationClosed => 'Registro cerrado'; } From bf326e2f09b1a9118fde9039a11393aecde1f374 Mon Sep 17 00:00:00 2001 From: alexdivadi Date: Sat, 14 Feb 2026 18:35:04 -0600 Subject: [PATCH 2/7] feat: add address search functionality --- assets/images/event_placeholder_img.png | Bin 0 -> 23821 bytes .../inputs/address_autocomplete_field.dart | 190 +++++++ .../inputs/any_step_address_field.dart | 470 ++++++++++++++++++ .../inputs/any_step_date_time_picker.dart | 13 + lib/core/common/widgets/inputs/inputs.dart | 2 + .../widgets/dashboard_metrics_card.dart | 12 +- .../events/data/event_repository.dart | 28 -- .../event_detail/event_detail_form.dart | 110 ++-- .../event_detail_form_controller.dart | 14 +- .../event_detail_form_controller.g.dart | 2 +- .../event_detail/event_detail_info.dart | 52 +- .../presentation/widgets/event_card.dart | 8 +- .../widgets/external_link_tile.dart | 39 ++ .../location/data/address_repository.dart | 143 +++++- .../location/data/places_api_client.dart | 88 ++++ .../location/data/places_api_client.g.dart | 52 ++ .../location/domain/places_models.dart | 79 +++ .../location/utils/place_to_address.dart | 65 +++ .../profile/data/user_repository.dart | 27 - .../onboarding/onboarding_screen.dart | 47 +- .../onboarding_screen_controller.dart | 14 +- .../onboarding_screen_controller.g.dart | 2 +- .../presentation/profile/profile_form.dart | 50 +- .../profile/profile_screen_controller.dart | 14 +- lib/env/env.dart | 1 + lib/l10n/app_en.arb | 8 + lib/l10n/app_es.arb | 8 + lib/l10n/generated/app_localizations.dart | 24 + lib/l10n/generated/app_localizations_en.dart | 12 + lib/l10n/generated/app_localizations_es.dart | 12 + 30 files changed, 1276 insertions(+), 310 deletions(-) create mode 100644 assets/images/event_placeholder_img.png create mode 100644 lib/core/common/widgets/inputs/address_autocomplete_field.dart create mode 100644 lib/core/common/widgets/inputs/any_step_address_field.dart create mode 100644 lib/core/features/events/presentation/widgets/external_link_tile.dart create mode 100644 lib/core/features/location/data/places_api_client.dart create mode 100644 lib/core/features/location/data/places_api_client.g.dart create mode 100644 lib/core/features/location/domain/places_models.dart create mode 100644 lib/core/features/location/utils/place_to_address.dart diff --git a/assets/images/event_placeholder_img.png b/assets/images/event_placeholder_img.png new file mode 100644 index 0000000000000000000000000000000000000000..f16ca4735f6ef49a470da7f7201ca795d457ccdc GIT binary patch literal 23821 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 literal 0 HcmV?d00001 diff --git a/lib/core/common/widgets/inputs/address_autocomplete_field.dart b/lib/core/common/widgets/inputs/address_autocomplete_field.dart new file mode 100644 index 0000000..828c92e --- /dev/null +++ b/lib/core/common/widgets/inputs/address_autocomplete_field.dart @@ -0,0 +1,190 @@ +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/widgets/inputs/any_step_text_field.dart'; +import 'package:anystep/core/features/location/data/places_api_client.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:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class AddressAutocompleteField extends ConsumerStatefulWidget { + const AddressAutocompleteField({ + super.key, + required this.formKey, + this.countryCode = 'US', + this.enabled = true, + }); + + final GlobalKey formKey; + final String countryCode; + final bool enabled; + + @override + ConsumerState createState() => _AddressAutocompleteFieldState(); +} + +class _AddressAutocompleteFieldState extends ConsumerState { + final TextEditingController _controller = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + Timer? _debounce; + List _predictions = []; + bool _isLoading = false; + String? _error; + + @override + void initState() { + super.initState(); + _focusNode.addListener(_handleFocusChange); + } + + @override + void dispose() { + _debounce?.cancel(); + _controller.dispose(); + _focusNode.removeListener(_handleFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + void _handleFocusChange() { + if (!_focusNode.hasFocus) { + setState(() { + _predictions = []; + _error = null; + }); + } + } + + void _onChanged(String? value) { + _debounce?.cancel(); + final query = value?.trim() ?? ''; + if (query.isEmpty) { + setState(() { + _predictions = []; + _error = null; + }); + return; + } + _debounce = Timer(const Duration(milliseconds: 300), () async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final results = await ref + .read(placesApiClientProvider) + .autocomplete(query, countryCode: widget.countryCode); + if (!mounted) return; + setState(() { + _predictions = results; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _predictions = []; + _isLoading = false; + _error = e.toString(); + }); + } + }); + } + + Future _selectPrediction(PlacesPrediction prediction) async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final details = await ref.read(placesApiClientProvider).placeDetails(prediction.placeId); + final parsed = placeDetailsToAddress(details); + final form = widget.formKey.currentState; + if (form == null) return; + 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['postalCode']?.didChange(parsed.postalCode); + form.fields['zipCode']?.didChange(parsed.postalCode); + form.fields['placeId']?.didChange(parsed.placeId); + form.fields['latitude']?.didChange(parsed.latitude); + form.fields['longitude']?.didChange(parsed.longitude); + form.fields['placeName']?.didChange(parsed.name ?? prediction.description); + _controller.text = prediction.description; + _controller.selection = + TextSelection.fromPosition(TextPosition(offset: _controller.text.length)); + setState(() { + _predictions = []; + _isLoading = false; + }); + } catch (e, stackTrace) { + Log.e('Places selection error', e, stackTrace); + if (!mounted) return; + setState(() { + _isLoading = false; + _error = e.toString(); + }); + } + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AnyStepTextField( + name: 'addressSearch', + labelText: loc.searchAddress, + hintText: loc.startTypingAddress, + controller: _controller, + focusNode: _focusNode, + enabled: widget.enabled, + onChanged: _onChanged, + textInputAction: TextInputAction.search, + ), + if (_isLoading) + Padding( + padding: const EdgeInsets.only(bottom: AnyStepSpacing.sm4), + child: LinearProgressIndicator(minHeight: 2), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: AnyStepSpacing.sm4), + child: Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + if (_predictions.isEmpty && !_isLoading && _controller.text.trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: AnyStepSpacing.sm4), + child: Text(loc.noMatchesFound), + ), + if (_predictions.isNotEmpty) + Card( + margin: const EdgeInsets.only(bottom: AnyStepSpacing.sm8), + child: ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _predictions.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final prediction = _predictions[index]; + return ListTile( + title: Text(prediction.mainText ?? prediction.description), + subtitle: + prediction.secondaryText != null ? Text(prediction.secondaryText!) : null, + onTap: () => _selectPrediction(prediction), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/core/common/widgets/inputs/any_step_address_field.dart b/lib/core/common/widgets/inputs/any_step_address_field.dart new file mode 100644 index 0000000..b59e4e5 --- /dev/null +++ b/lib/core/common/widgets/inputs/any_step_address_field.dart @@ -0,0 +1,470 @@ +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/widgets/inputs/any_step_text_field.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'; + +class AnyStepAddressField extends ConsumerStatefulWidget { + const AnyStepAddressField({ + super.key, + required this.formKey, + this.countryCode = 'US', + this.enabled = true, + this.disableSearch = false, + this.includeEventAddresses = true, + this.includeUserAddresses = true, + this.isUserAddress = false, + this.initialAddressId, + this.addressIdFieldName = 'addressId', + this.onAddressSaved, + this.streetLabelText, + this.streetSecondaryLabelText, + this.cityLabelText, + this.stateLabelText, + this.postalCodeLabelText, + this.postalCodeFieldName = 'postalCode', + this.streetValidator, + this.streetSecondaryValidator, + this.cityValidator, + this.stateValidator, + this.postalCodeValidator, + this.initialStreet, + this.initialStreetSecondary, + this.initialCity, + this.initialState, + this.initialPostalCode, + }); + + final GlobalKey formKey; + final String countryCode; + final bool enabled; + final bool disableSearch; + final bool includeEventAddresses; + final bool includeUserAddresses; + final bool isUserAddress; + final int? initialAddressId; + final String addressIdFieldName; + final ValueChanged? onAddressSaved; + final String? streetLabelText; + final String? streetSecondaryLabelText; + final String? cityLabelText; + final String? stateLabelText; + final String? postalCodeLabelText; + final String postalCodeFieldName; + 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? initialStreet; + final String? initialStreetSecondary; + final String? initialCity; + final String? initialState; + final String? initialPostalCode; + + @override + ConsumerState createState() => _AnyStepAddressFieldState(); +} + +class _AnyStepAddressFieldState extends ConsumerState { + final TextEditingController _streetController = TextEditingController(); + final FocusNode _streetFocusNode = FocusNode(); + final FocusNode _cityFocusNode = FocusNode(); + final FocusNode _stateFocusNode = FocusNode(); + final FocusNode _postalCodeFocusNode = FocusNode(); + Timer? _debounce; + bool _isLoading = false; + bool _isSearchDisabled = false; + bool _isApplyingSelection = false; + List _dbResults = []; + List _predictions = []; + String? _error; + String? _placeId; + String? _placeName; + double? _latitude; + double? _longitude; + + @override + void initState() { + super.initState(); + _streetController.text = widget.initialStreet ?? ''; + _streetFocusNode.addListener(_handleFocusChange); + _cityFocusNode.addListener(_handleFieldBlur); + _stateFocusNode.addListener(_handleFieldBlur); + _postalCodeFocusNode.addListener(_handleFieldBlur); + _isSearchDisabled = widget.disableSearch || Env.placesApiKey.isEmpty; + if (widget.initialAddressId != null) { + WidgetsBinding.instance.addPostFrameCallback((_) => _loadInitialAddress()); + } + } + + @override + void didUpdateWidget(covariant AnyStepAddressField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.disableSearch && !_isSearchDisabled) { + _isSearchDisabled = true; + } else if (Env.placesApiKey.isEmpty) { + _isSearchDisabled = true; + } + } + + @override + void dispose() { + _debounce?.cancel(); + _streetController.dispose(); + _streetFocusNode.removeListener(_handleFocusChange); + _streetFocusNode.dispose(); + _cityFocusNode.removeListener(_handleFieldBlur); + _cityFocusNode.dispose(); + _stateFocusNode.removeListener(_handleFieldBlur); + _stateFocusNode.dispose(); + _postalCodeFocusNode.removeListener(_handleFieldBlur); + _postalCodeFocusNode.dispose(); + super.dispose(); + } + + void _handleFocusChange() { + if (!_streetFocusNode.hasFocus) { + _maybeSaveAddress(); + setState(() { + _dbResults = []; + _predictions = []; + _error = null; + }); + } + } + + void _handleFieldBlur() { + if (!_cityFocusNode.hasFocus && !_stateFocusNode.hasFocus && !_postalCodeFocusNode.hasFocus) { + _maybeSaveAddress(); + } + } + + Future> _searchDb(String query) async { + if (!widget.includeEventAddresses && !widget.includeUserAddresses) return []; + final repo = ref.read(addressRepositoryProvider); + return repo.search( + query: query, + includeEventAddresses: widget.includeEventAddresses, + includeUserAddresses: widget.includeUserAddresses, + ); + } + + void _clearPlaceFieldsIfManualEdit() { + if (_isApplyingSelection) return; + final form = widget.formKey.currentState; + if (form == null) return; + _placeId = null; + _placeName = null; + _latitude = null; + _longitude = null; + form.fields[widget.addressIdFieldName]?.didChange(null); + widget.onAddressSaved?.call(null); + } + + void _onStreetChanged(String? value) { + _clearPlaceFieldsIfManualEdit(); + _debounce?.cancel(); + final query = value?.trim() ?? ''; + if (query.isEmpty) { + setState(() { + _dbResults = []; + _predictions = []; + _error = null; + }); + return; + } + _debounce = Timer(const Duration(milliseconds: 300), () async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final dbResults = await _searchDb(query); + if (!mounted) return; + var predictions = []; + if (!_isSearchDisabled) { + try { + predictions = await ref + .read(placesApiClientProvider) + .autocomplete(query, countryCode: widget.countryCode); + } catch (e) { + _isSearchDisabled = true; + _error = e.toString(); + } + } + if (!mounted) return; + setState(() { + _dbResults = dbResults; + _predictions = predictions; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _dbResults = []; + _predictions = []; + _isLoading = false; + _error = e.toString(); + }); + } + }); + } + + void _selectAddress(AddressModel address) { + final form = widget.formKey.currentState; + if (form == null) return; + _isApplyingSelection = true; + 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; + _latitude = address.latitude; + _longitude = address.longitude; + form.fields[widget.addressIdFieldName]?.didChange(address.id); + widget.onAddressSaved?.call(address.id); + _streetController.text = address.street; + _streetController.selection = + TextSelection.fromPosition(TextPosition(offset: _streetController.text.length)); + _streetFocusNode.unfocus(); + _isApplyingSelection = false; + setState(() { + _dbResults = []; + _predictions = []; + }); + } + + Future _selectPrediction(PlacesPrediction prediction) async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final details = await ref.read(placesApiClientProvider).placeDetails(prediction.placeId); + final parsed = placeDetailsToAddress(details); + final form = widget.formKey.currentState; + if (form == null) return; + 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 ?? prediction.description; + _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)); + _streetFocusNode.unfocus(); + _isApplyingSelection = false; + await _saveAddressIfComplete(); + setState(() { + _dbResults = []; + _predictions = []; + _isLoading = false; + }); + } catch (e, stackTrace) { + Log.e('Places selection error', e, stackTrace); + if (!mounted) return; + setState(() { + _isLoading = false; + _error = e.toString(); + }); + } + } + + Future _loadInitialAddress() async { + final form = widget.formKey.currentState; + if (form == null || widget.initialAddressId == null) return; + try { + final repo = ref.read(addressRepositoryProvider); + final address = await repo.get(documentId: widget.initialAddressId.toString()); + _placeId = address.placeId; + _placeName = address.name ?? address.formattedAddress; + _latitude = address.latitude; + _longitude = address.longitude; + 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); + form.fields[widget.addressIdFieldName]?.didChange(address.id); + _streetController.text = address.street; + } catch (e, stackTrace) { + Log.e('Error loading address', e, stackTrace); + } + } + + void _maybeSaveAddress() { + if (_isApplyingSelection) return; + _saveAddressIfComplete(); + } + + Future _saveAddressIfComplete() async { + final form = widget.formKey.currentState; + if (form == null) return; + 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 postal = form.fields[widget.postalCodeFieldName]?.value?.toString().trim() ?? ''; + if (street.isEmpty || city.isEmpty || state.isEmpty || postal.isEmpty) return; + final address = AddressModel.withGeohash( + street: street, + streetSecondary: form.fields['streetSecondary']?.value?.toString(), + city: city, + state: state, + country: widget.countryCode, + postalCode: postal, + isUserAddress: widget.isUserAddress, + latitude: _latitude, + longitude: _longitude, + placeId: _placeId, + name: _placeName, + ); + try { + final repo = ref.read(addressRepositoryProvider); + final saved = await repo.createOrUpdate(obj: address); + form.fields[widget.addressIdFieldName]?.didChange(saved.id); + widget.onAddressSaved?.call(saved.id); + } catch (e, stackTrace) { + Log.e('Error saving address', e, stackTrace); + } + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AnyStepTextField( + name: 'street', + labelText: widget.streetLabelText ?? loc.streetAddress, + hintText: loc.startTypingAddress, + controller: _streetController, + focusNode: _streetFocusNode, + enabled: widget.enabled, + onChanged: _onStreetChanged, + textInputAction: TextInputAction.search, + validator: widget.streetValidator ?? FormBuilderValidators.street(), + ), + if (_isLoading) + const Padding( + padding: EdgeInsets.only(bottom: AnyStepSpacing.sm4), + child: LinearProgressIndicator(minHeight: 2), + ), + if (_error != null && !_isSearchDisabled) + Padding( + padding: const EdgeInsets.only(bottom: AnyStepSpacing.sm4), + child: Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + if (_dbResults.isEmpty && + _predictions.isEmpty && + !_isLoading && + _streetController.text.trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: AnyStepSpacing.sm4), + child: Text(loc.noMatchesFound), + ), + if (_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; + return ListTile( + title: Text(title), + subtitle: subtitle != null ? Text(subtitle) : null, + onTap: () => _selectAddress(address), + ); + } + final prediction = _predictions[index - _dbResults.length]; + return ListTile( + title: Text(prediction.mainText ?? prediction.description), + subtitle: + prediction.secondaryText != null ? Text(prediction.secondaryText!) : null, + onTap: () => _selectPrediction(prediction), + ); + }, + ), + ), + FormBuilderField( + name: widget.addressIdFieldName, + initialValue: widget.initialAddressId, + builder: (field) => const SizedBox.shrink(), + ), + AnyStepTextField( + name: 'streetSecondary', + initialValue: widget.initialStreetSecondary, + labelText: widget.streetSecondaryLabelText ?? loc.apartmentSuiteOptional, + validator: + widget.streetSecondaryValidator ?? + FormBuilderValidators.street(checkNullOrEmpty: false), + ), + Row( + children: [ + Flexible( + flex: 4, + child: AnyStepTextField( + name: 'city', + initialValue: widget.initialCity, + labelText: widget.cityLabelText ?? loc.city, + validator: widget.cityValidator ?? FormBuilderValidators.city(), + focusNode: _cityFocusNode, + ), + ), + const SizedBox(width: AnyStepSpacing.sm2), + Flexible( + flex: 2, + child: AnyStepTextField( + name: 'state', + initialValue: widget.initialState, + labelText: widget.stateLabelText ?? loc.state, + validator: widget.stateValidator ?? FormBuilderValidators.state(), + focusNode: _stateFocusNode, + ), + ), + const SizedBox(width: AnyStepSpacing.sm2), + Flexible( + flex: 3, + child: AnyStepTextField( + name: widget.postalCodeFieldName, + initialValue: widget.initialPostalCode, + labelText: widget.postalCodeLabelText ?? loc.postalCode, + keyboardType: TextInputType.number, + validator: widget.postalCodeValidator ?? FormBuilderValidators.zipCode(), + focusNode: _postalCodeFocusNode, + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/core/common/widgets/inputs/any_step_date_time_picker.dart b/lib/core/common/widgets/inputs/any_step_date_time_picker.dart index 75c7464..c3d039e 100644 --- a/lib/core/common/widgets/inputs/any_step_date_time_picker.dart +++ b/lib/core/common/widgets/inputs/any_step_date_time_picker.dart @@ -17,6 +17,7 @@ class AnyStepDateTimePicker extends StatelessWidget { this.useDefaultInitialValue = true, this.firstDate, this.lastDate, + this.allowClear = false, }); final String name; @@ -30,6 +31,7 @@ class AnyStepDateTimePicker extends StatelessWidget { final bool useDefaultInitialValue; final DateTime? firstDate; final DateTime? lastDate; + final bool allowClear; @override Widget build(BuildContext context) { @@ -48,6 +50,17 @@ class AnyStepDateTimePicker extends StatelessWidget { labelStyle: TextStyle(color: labelColor), floatingLabelStyle: TextStyle(color: labelColor), hintStyle: TextStyle(color: hintColor), + suffixIcon: allowClear + ? IconButton( + icon: const Icon(Icons.close), + tooltip: 'Clear', + color: Theme.of(context).colorScheme.onSurface.withAlpha(80), + onPressed: () { + FormBuilder.of(context)?.fields[name]?.didChange(null); + onChanged?.call(null); + }, + ) + : null, border: OutlineInputBorder( borderRadius: const BorderRadius.all(Radius.circular(AnyStepSpacing.md16)), ), diff --git a/lib/core/common/widgets/inputs/inputs.dart b/lib/core/common/widgets/inputs/inputs.dart index d5b3579..f29b859 100644 --- a/lib/core/common/widgets/inputs/inputs.dart +++ b/lib/core/common/widgets/inputs/inputs.dart @@ -2,4 +2,6 @@ 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 'address_autocomplete_field.dart'; export 'image_upload_widget.dart'; diff --git a/lib/core/features/dashboard/presentation/widgets/dashboard_metrics_card.dart b/lib/core/features/dashboard/presentation/widgets/dashboard_metrics_card.dart index 161a63c..63f01ab 100644 --- a/lib/core/features/dashboard/presentation/widgets/dashboard_metrics_card.dart +++ b/lib/core/features/dashboard/presentation/widgets/dashboard_metrics_card.dart @@ -41,7 +41,7 @@ class DashboardMetricsCard extends StatelessWidget { final loc = AppLocalizations.of(context); final theme = Theme.of(context); final onSurface = theme.colorScheme.onSurface; - final pillHeight = 82.0; + final pillHeight = 72.0; return Padding( padding: const EdgeInsets.symmetric(horizontal: AnyStepSpacing.md16), @@ -86,7 +86,7 @@ class DashboardMetricsCard extends StatelessWidget { LayoutBuilder( builder: (context, constraints) { final maxWidth = constraints.maxWidth; - final spacing = AnyStepSpacing.md12; + final spacing = AnyStepSpacing.sm8; final columns = maxWidth >= 720 ? 3 : 2; final itemWidth = (maxWidth - (columns - 1) * spacing).clamp(160, double.infinity) / columns; @@ -255,7 +255,7 @@ class _MetricPill extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric( horizontal: AnyStepSpacing.md12, - vertical: AnyStepSpacing.sm8, + vertical: AnyStepSpacing.sm6, ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest.withAlpha(70), @@ -270,15 +270,15 @@ class _MetricPill extends StatelessWidget { overflow: TextOverflow.ellipsis, style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), - const SizedBox(height: AnyStepSpacing.sm6), + const SizedBox(height: AnyStepSpacing.sm4), DefaultTextStyle( style: theme.textTheme.headlineSmall?.copyWith( color: theme.colorScheme.onSurface, fontWeight: FontWeight.w800, - fontSize: 22, + fontSize: 20, ) ?? - const TextStyle(fontSize: 22, fontWeight: FontWeight.w800), + const TextStyle(fontSize: 20, fontWeight: FontWeight.w800), child: value, ), ], diff --git a/lib/core/features/events/data/event_repository.dart b/lib/core/features/events/data/event_repository.dart index 9525080..855c181 100644 --- a/lib/core/features/events/data/event_repository.dart +++ b/lib/core/features/events/data/event_repository.dart @@ -1,4 +1,3 @@ -import 'package:anystep/core/common/utils/log_utils.dart'; import 'package:anystep/database/database.dart'; import 'package:anystep/database/filter.dart'; import 'package:anystep/core/common/data/irepository.dart'; @@ -13,43 +12,16 @@ class EventRepository implements IRepository { const EventRepository({ required this.database, this.collectionId = Env.eventCollectionId, - this.addressCollectionId = Env.addressCollectionId, }); final Database database; final String collectionId; - final String addressCollectionId; static const int pageSize = 25; @override Future createOrUpdate({required EventModel obj, String? documentId}) async { final data = obj.toJson(); - - final address = obj.address; - if (address != null) { - try { - final addressDoc = await database.list( - table: addressCollectionId, - filters: [ - AnyStepFilter.equals('street', address.street), - AnyStepFilter.equals('street_secondary', address.streetSecondary), - AnyStepFilter.equals('city', address.city), - AnyStepFilter.equals('state', address.state), - AnyStepFilter.equals('postal_code', address.postalCode), - AnyStepFilter.equals('country', address.country), - ], - ); - data['address'] = addressDoc.isNotEmpty - ? addressDoc.first["id"] - : (await database.createOrUpdate( - table: addressCollectionId, - data: address.toJson(), - )).first["id"]; - } catch (e) { - Log.e("Error handling address", e); - } - } final document = await database.createOrUpdate(table: collectionId, data: data); return EventModel.fromJson(document.first); } 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 6e861b6..b145a7f 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 @@ -136,50 +136,16 @@ class _EventDetailFormState extends ConsumerState { ), const SizedBox(height: AnyStepSpacing.sm4), - AnyStepTextField( - name: 'street', - initialValue: widget.event?.address?.street, - labelText: loc.streetAddress, - validator: FormBuilderValidators.required(), - ), - AnyStepTextField( - name: 'streetSecondary', - initialValue: widget.event?.address?.streetSecondary, - labelText: loc.apartmentSuiteOptional, - ), - Row( - children: [ - Flexible( - flex: 4, - child: AnyStepTextField( - name: 'city', - initialValue: widget.event?.address?.city, - labelText: loc.city, - validator: FormBuilderValidators.city(), - ), - ), - const SizedBox(width: AnyStepSpacing.sm2), - Flexible( - flex: 2, - child: AnyStepTextField( - name: 'state', - initialValue: widget.event?.address?.state, - labelText: loc.state, - validator: FormBuilderValidators.state(), - ), - ), - const SizedBox(width: AnyStepSpacing.sm2), - Flexible( - flex: 3, - child: AnyStepTextField( - name: 'postalCode', - initialValue: widget.event?.address?.postalCode, - labelText: loc.postalCode, - keyboardType: TextInputType.number, - validator: FormBuilderValidators.zipCode(), - ), - ), - ], + AnyStepAddressField( + formKey: formKey, + initialAddressId: widget.event?.addressId ?? widget.event?.address?.id, + isUserAddress: false, + includeEventAddresses: true, + includeUserAddresses: false, + streetValidator: FormBuilderValidators.required(), + streetSecondaryValidator: FormBuilderValidators.street( + checkNullOrEmpty: false, + ), ), Theme( data: Theme.of(context).copyWith(dividerColor: Colors.transparent), @@ -188,40 +154,28 @@ class _EventDetailFormState extends ConsumerState { childrenPadding: const EdgeInsets.only(bottom: AnyStepSpacing.sm4), title: Text(loc.advancedOptions), children: [ - Row( - children: [ - Flexible( - flex: 2, - child: AnyStepTextField( - name: 'maxVolunteers', - initialValue: widget.event?.maxVolunteers?.toString(), - labelText: loc.maxVolunteersOptional, - keyboardType: TextInputType.number, - validator: FormBuilderValidators.integer(checkNullOrEmpty: false), - ), - ), - const SizedBox(width: AnyStepSpacing.sm2), - Flexible( - flex: 3, - child: AnyStepDateTimePicker( - name: 'registrationDeadline', - labelText: loc.registrationDeadlineOptional, - initialValue: widget.event?.registrationDeadline?.toLocal(), - useDefaultInitialValue: false, - lastDate: _startTime, - validator: FormBuilderValidators.compose([ - (val) { - if (val != null && - _startTime != null && - val.isAfter(_startTime!)) { - return 'Deadline must be before event start time'; - } - return null; - }, - ]), - ), - ), - ], + AnyStepTextField( + name: 'maxVolunteers', + initialValue: widget.event?.maxVolunteers?.toString(), + labelText: loc.maxVolunteersOptional, + keyboardType: TextInputType.number, + validator: FormBuilderValidators.integer(checkNullOrEmpty: false), + ), + AnyStepDateTimePicker( + name: 'registrationDeadline', + labelText: loc.registrationDeadlineOptional, + initialValue: widget.event?.registrationDeadline?.toLocal(), + useDefaultInitialValue: false, + lastDate: _startTime, + allowClear: true, + validator: FormBuilderValidators.compose([ + (val) { + if (val != null && _startTime != null && val.isAfter(_startTime!)) { + return 'Deadline must be before event start time'; + } + return null; + }, + ]), ), AnyStepTextField( name: 'externalLink', diff --git a/lib/core/features/events/presentation/event_detail/event_detail_form_controller.dart b/lib/core/features/events/presentation/event_detail/event_detail_form_controller.dart index 7928c34..62adfcb 100644 --- a/lib/core/features/events/presentation/event_detail/event_detail_form_controller.dart +++ b/lib/core/features/events/presentation/event_detail/event_detail_form_controller.dart @@ -3,7 +3,6 @@ import 'package:anystep/core/config/posthog/posthog_manager.dart'; import 'package:anystep/core/features/events/data/event_repository.dart'; import 'package:anystep/core/features/events/domain/event.dart'; import 'package:anystep/core/features/events/presentation/event_detail/event_detail_form_state.dart'; -import 'package:anystep/core/features/location/domain/address_model.dart'; import 'package:anystep/database/storage.dart'; // Added import for storage provider import 'package:image_picker/image_picker.dart'; // Import XFile for image upload import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -27,6 +26,9 @@ class EventDetailFormController extends _$EventDetailFormController { (externalLinkRaw == null || externalLinkRaw.toString().trim().isEmpty) ? null : externalLinkRaw.toString(); + final addressIdRaw = values['addressId']; + final addressId = + addressIdRaw is int ? addressIdRaw : int.tryParse(addressIdRaw?.toString() ?? ''); final event = EventModel( id: state.eventId, @@ -34,15 +36,7 @@ class EventDetailFormController extends _$EventDetailFormController { startTime: (values['startTime'] as DateTime).toUtc(), endTime: (values['endTime'] as DateTime).toUtc(), imageUrl: values['imageUrl'], - address: AddressModel( - street: values['street']!, - streetSecondary: values['streetSecondary'], - city: values['city']!, - state: values['state']!, - country: 'US', - postalCode: values['postalCode']!, - isUserAddress: false, - ), + addressId: addressId, description: values['description'], isVolunteerEligible: values['isVolunteerEligible'] ?? true, maxVolunteers: maxVolunteers, diff --git a/lib/core/features/events/presentation/event_detail/event_detail_form_controller.g.dart b/lib/core/features/events/presentation/event_detail/event_detail_form_controller.g.dart index acd6aa5..fedc266 100644 --- a/lib/core/features/events/presentation/event_detail/event_detail_form_controller.g.dart +++ b/lib/core/features/events/presentation/event_detail/event_detail_form_controller.g.dart @@ -60,7 +60,7 @@ final class EventDetailFormControllerProvider } String _$eventDetailFormControllerHash() => - r'7a7702d263176f0b26f413df8f9c72989bf89bcd'; + r'91d431846ae59e9bda5d897d42a0c1fca9f00a37'; final class EventDetailFormControllerFamily extends $Family with diff --git a/lib/core/features/events/presentation/event_detail/event_detail_info.dart b/lib/core/features/events/presentation/event_detail/event_detail_info.dart index 69d22d7..425b904 100644 --- a/lib/core/features/events/presentation/event_detail/event_detail_info.dart +++ b/lib/core/features/events/presentation/event_detail/event_detail_info.dart @@ -5,11 +5,12 @@ import 'package:anystep/core/common/widgets/dropdown_section.dart'; import 'package:anystep/core/config/theme/colors.dart'; import 'package:anystep/core/features/events/domain/event.dart'; import 'package:anystep/core/features/events/presentation/widgets/did_attend_indicator.dart'; +import 'package:anystep/core/features/events/presentation/widgets/external_link_tile.dart'; import 'package:anystep/core/features/events/presentation/widgets/event_time_table.dart'; import 'package:anystep/core/features/location/utils/launch_map.dart'; import 'package:anystep/l10n/generated/app_localizations.dart'; import 'package:flutter/material.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:intl/intl.dart'; class EventDetailInfo extends StatelessWidget { const EventDetailInfo({super.key, required this.event}); @@ -148,47 +149,20 @@ class EventDetailInfo extends StatelessWidget { : null, ), if (event.externalLink != null && event.externalLink!.trim().isNotEmpty) - _ExternalLinkTile(url: event.externalLink!, label: loc.externalLink), + ExternalLinkTile(url: event.externalLink!, label: loc.externalLink), + if (event.registrationDeadline != null) + ListTile( + leading: const Icon(Icons.event_busy), + title: Text(loc.registrationDeadline), + subtitle: Text( + DateFormat('MMM d, yyyy • h:mm a').format( + event.registrationDeadline!.toLocal(), + ), + ), + ), ], ), ), ); } } - -class _ExternalLinkTile extends StatelessWidget { - const _ExternalLinkTile({required this.url, required this.label}); - - final String url; - final String label; - - Uri? _buildUri() { - final trimmed = url.trim(); - if (trimmed.isEmpty) return null; - final parsed = Uri.tryParse(trimmed); - if (parsed == null) return null; - if (parsed.hasScheme) return parsed; - return Uri.tryParse('https://$trimmed'); - } - - @override - Widget build(BuildContext context) { - return ListTile( - leading: const Icon(Icons.open_in_new), - title: Text(label, style: Theme.of(context).textTheme.titleMedium), - subtitle: Text(url), - onTap: () async { - final uri = _buildUri(); - if (uri == null) return; - try { - final launched = await launchUrl(uri, mode: LaunchMode.externalApplication); - if (!launched) { - Log.e('Failed to open external link: $uri'); - } - } catch (e) { - Log.e('Error opening external link', e); - } - }, - ); - } -} diff --git a/lib/core/features/events/presentation/widgets/event_card.dart b/lib/core/features/events/presentation/widgets/event_card.dart index 19f0d80..8c328b6 100644 --- a/lib/core/features/events/presentation/widgets/event_card.dart +++ b/lib/core/features/events/presentation/widgets/event_card.dart @@ -83,14 +83,10 @@ class _ImagePlaceholder extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( + return Image.asset( + 'assets/images/event_placeholder_img.png', width: AnyStepSpacing.xl56, height: AnyStepSpacing.xl56, - decoration: BoxDecoration( - color: Theme.of(context).highlightColor.withAlpha(100), - borderRadius: BorderRadius.circular(AnyStepSpacing.sm8), - ), - child: Icon(Icons.event, color: Theme.of(context).highlightColor), ); } } diff --git a/lib/core/features/events/presentation/widgets/external_link_tile.dart b/lib/core/features/events/presentation/widgets/external_link_tile.dart new file mode 100644 index 0000000..b448fae --- /dev/null +++ b/lib/core/features/events/presentation/widgets/external_link_tile.dart @@ -0,0 +1,39 @@ +import 'package:anystep/core/common/utils/log_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class ExternalLinkTile extends StatelessWidget { + const ExternalLinkTile({super.key, required this.url, required this.label}); + + final String url; + final String label; + + Uri? _buildUri() { + final trimmed = url.trim(); + if (trimmed.isEmpty) return null; + final parsed = Uri.tryParse(trimmed); + if (parsed == null) return null; + if (parsed.hasScheme) return parsed; + return Uri.tryParse('https://$trimmed'); + } + + @override + Widget build(BuildContext context) { + return ListTile( + leading: const Icon(Icons.open_in_new), + title: Text(url), + onTap: () async { + final uri = _buildUri(); + if (uri == null) return; + try { + final launched = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!launched) { + Log.e('Failed to open external link: $uri'); + } + } catch (e) { + Log.e('Error opening external link', e); + } + }, + ); + } +} diff --git a/lib/core/features/location/data/address_repository.dart b/lib/core/features/location/data/address_repository.dart index f86bca1..7e014f0 100644 --- a/lib/core/features/location/data/address_repository.dart +++ b/lib/core/features/location/data/address_repository.dart @@ -1,6 +1,7 @@ import 'package:anystep/database/database.dart'; import 'package:anystep/database/filter.dart'; import 'package:anystep/core/common/data/irepository.dart'; +import 'package:anystep/core/common/utils/log_utils.dart'; import 'package:anystep/core/features/location/domain/address_model.dart'; import 'package:anystep/env/env.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -12,16 +13,100 @@ class AddressRepository implements IRepository { final Database database; final String collectionId; + Future> search({ + required String query, + bool includeEventAddresses = true, + bool includeUserAddresses = true, + int limit = 6, + }) async { + final trimmed = query.trim(); + if (trimmed.isEmpty) return []; + if (!includeEventAddresses && !includeUserAddresses) return []; + + final baseFilters = []; + if (includeEventAddresses != includeUserAddresses) { + baseFilters.add(AnyStepFilter.equals('is_user_address', includeUserAddresses)); + } + + final results = {}; + final searchColumns = [ + 'street', + 'street_secondary', + 'city', + 'state', + 'postal_code', + 'name', + ]; + final likeValue = "%$trimmed%"; + + for (final column in searchColumns) { + final filters = [...baseFilters, AnyStepFilter.like(column, likeValue)]; + final documents = await database.list( + table: collectionId, + filters: filters, + limit: limit, + ); + for (final doc in documents) { + final model = AddressModel.fromJson(doc); + final key = model.id?.toString() ?? model.formattedAddress; + results.putIfAbsent(key, () => model); + if (results.length >= limit) break; + } + if (results.length >= limit) break; + } + + return results.values.take(limit).toList(); + } + @override Future createOrUpdate({required AddressModel obj, String? documentId}) async { - // TODO: implement createOrUpdate - throw UnimplementedError(); + final normalized = _normalizeAddress(obj); + List> matches = []; + + if (normalized.placeId != null && normalized.placeId!.trim().isNotEmpty) { + matches = await database.list( + table: collectionId, + filters: [ + AnyStepFilter.equals('place_id', normalized.placeId), + AnyStepFilter.equals('is_user_address', normalized.isUserAddress), + ], + limit: 1, + ); + } + + if (matches.isEmpty) { + matches = await database.list( + table: collectionId, + filters: [ + AnyStepFilter.equals('street', normalized.street), + AnyStepFilter.equals('street_secondary', normalized.streetSecondary), + AnyStepFilter.equals('city', normalized.city), + AnyStepFilter.equals('state', normalized.state), + AnyStepFilter.equals('postal_code', normalized.postalCode), + AnyStepFilter.equals('country', normalized.country), + AnyStepFilter.equals('is_user_address', normalized.isUserAddress), + ], + limit: 1, + ); + } + + if (matches.isNotEmpty) { + final existing = AddressModel.fromJson(matches.first); + final merged = _mergeAddress(existing, normalized); + final data = merged.toJson()..['id'] = existing.id; + final updated = await database.createOrUpdate(table: collectionId, data: data); + return AddressModel.fromJson(updated.first); + } + + Log.w('Creating new address record', normalized.toJson()); + final created = await database.createOrUpdate(table: collectionId, data: normalized.toJson()); + return AddressModel.fromJson(created.first); } @override - Future get({required String documentId}) { - // TODO: AddressModel get - throw UnimplementedError(); + Future get({required String documentId}) async { + final document = await database.get(table: collectionId, documentId: documentId); + return AddressModel.fromJson(document); } @override @@ -30,9 +115,15 @@ class AddressRepository implements IRepository { AnyStepOrder? order, int? limit, int? page, - }) { - // TODO: implement list - throw UnimplementedError(); + }) async { + final documents = await database.list( + table: collectionId, + filters: filters, + order: order, + limit: limit, + page: page, + ); + return documents.map((doc) => AddressModel.fromJson(doc)).toList(); } @override @@ -42,6 +133,42 @@ class AddressRepository implements IRepository { } } +String _collapseWhitespace(String value) { + return value.trim().replaceAll(RegExp(r'\s+'), ' '); +} + +AddressModel _normalizeAddress(AddressModel address) { + final street = _collapseWhitespace(address.street); + final streetSecondary = + address.streetSecondary == null || address.streetSecondary!.trim().isEmpty + ? null + : _collapseWhitespace(address.streetSecondary!); + final city = _collapseWhitespace(address.city); + final state = _collapseWhitespace(address.state).toUpperCase(); + final postalCode = _collapseWhitespace(address.postalCode); + final country = (address.country == null || address.country!.trim().isEmpty) + ? 'US' + : _collapseWhitespace(address.country!).toUpperCase(); + return address.copyWith( + street: street, + streetSecondary: streetSecondary, + city: city, + state: state, + postalCode: postalCode, + country: country, + ); +} + +AddressModel _mergeAddress(AddressModel existing, AddressModel incoming) { + return existing.copyWith( + latitude: existing.latitude ?? incoming.latitude, + longitude: existing.longitude ?? incoming.longitude, + placeId: existing.placeId ?? incoming.placeId, + name: existing.name ?? incoming.name, + geohash: existing.geohash ?? incoming.geohash, + ); +} + @riverpod AddressRepository addressRepository(Ref ref) { final database = ref.watch(databaseProvider); diff --git a/lib/core/features/location/data/places_api_client.dart b/lib/core/features/location/data/places_api_client.dart new file mode 100644 index 0000000..2123c13 --- /dev/null +++ b/lib/core/features/location/data/places_api_client.dart @@ -0,0 +1,88 @@ +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: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; + + final Dio _dio; + final String _apiKey; + final Map _detailsCache = {}; + + Future> autocomplete(String query, {String countryCode = 'US'}) async { + if (query.trim().isEmpty) return []; + if (_apiKey.isEmpty) { + Log.w('Places API key missing; set GOOGLE_PLACES_API_KEY via --dart-define.'); + return []; + } + 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) + .toList(); + } catch (e, stackTrace) { + Log.e('Places autocomplete 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; + } + } +} + +@riverpod +PlacesApiClient placesApiClient(Ref ref) { + return PlacesApiClient(); +} diff --git a/lib/core/features/location/data/places_api_client.g.dart b/lib/core/features/location/data/places_api_client.g.dart new file mode 100644 index 0000000..95758a8 --- /dev/null +++ b/lib/core/features/location/data/places_api_client.g.dart @@ -0,0 +1,52 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'places_api_client.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(placesApiClient) +const placesApiClientProvider = PlacesApiClientProvider._(); + +final class PlacesApiClientProvider + extends + $FunctionalProvider + with $Provider { + const PlacesApiClientProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'placesApiClientProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$placesApiClientHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + PlacesApiClient create(Ref ref) { + return placesApiClient(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(PlacesApiClient value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$placesApiClientHash() => r'd686f2e8f8906ef334f439ebbf496e015f972522'; diff --git a/lib/core/features/location/domain/places_models.dart b/lib/core/features/location/domain/places_models.dart new file mode 100644 index 0000000..8716767 --- /dev/null +++ b/lib/core/features/location/domain/places_models.dart @@ -0,0 +1,79 @@ +class PlacesPrediction { + PlacesPrediction({ + required this.placeId, + required this.description, + this.mainText, + this.secondaryText, + }); + + final String placeId; + 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?; + 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, + ); + } +} + +class PlaceDetails { + PlaceDetails({ + required this.placeId, + required this.name, + required this.addressComponents, + required this.latitude, + required this.longitude, + }); + + final String placeId; + final String? name; + final List addressComponents; + 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?; + 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(), + ); + } +} diff --git a/lib/core/features/location/utils/place_to_address.dart b/lib/core/features/location/utils/place_to_address.dart new file mode 100644 index 0000000..e14a52a --- /dev/null +++ b/lib/core/features/location/utils/place_to_address.dart @@ -0,0 +1,65 @@ +import 'package:anystep/core/features/location/domain/places_models.dart'; + +class ParsedPlaceAddress { + ParsedPlaceAddress({ + required this.street, + required this.city, + required this.state, + required this.postalCode, + required this.country, + this.streetSecondary, + this.placeId, + this.name, + this.latitude, + this.longitude, + }); + + final String street; + final String? streetSecondary; + final String city; + final String state; + final String postalCode; + final String country; + final String? placeId; + final String? name; + final double? latitude; + final double? longitude; +} + +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? 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 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'; + + return ParsedPlaceAddress( + street: street, + streetSecondary: streetSecondary, + city: city, + state: state, + postalCode: postalCode, + country: country, + placeId: details.placeId, + name: details.name, + latitude: details.latitude, + longitude: details.longitude, + ); +} diff --git a/lib/core/features/profile/data/user_repository.dart b/lib/core/features/profile/data/user_repository.dart index 42c9961..b8961f2 100644 --- a/lib/core/features/profile/data/user_repository.dart +++ b/lib/core/features/profile/data/user_repository.dart @@ -1,5 +1,4 @@ import 'package:anystep/core/common/data/irepository.dart'; -import 'package:anystep/core/common/utils/log_utils.dart'; import 'package:anystep/core/features/profile/domain/user_model.dart'; import 'package:anystep/database/database.dart'; import 'package:anystep/database/filter.dart'; @@ -16,37 +15,11 @@ class UserRepository implements IRepository { final Database database; final String collectionId; - final String addressCollectionId = Env.addressCollectionId; @override Future createOrUpdate({required UserModel obj, String? documentId}) async { final data = obj.toJson(); - final address = obj.address; - if (address != null) { - try { - final addressDoc = await database.list( - table: addressCollectionId, - filters: [ - AnyStepFilter.equals('street', address.street), - AnyStepFilter.equals('street_secondary', address.streetSecondary), - AnyStepFilter.equals('city', address.city), - AnyStepFilter.equals('state', address.state), - AnyStepFilter.equals('postal_code', address.postalCode), - AnyStepFilter.equals('country', address.country), - ], - ); - data['address'] = addressDoc.isNotEmpty - ? addressDoc.first["id"] - : (await database.createOrUpdate( - table: addressCollectionId, - data: address.toJson(), - )).first["id"]; - } catch (e) { - Log.e("Error handling address", e); - } - } - final document = await database.createOrUpdate(table: collectionId, data: data); return UserModel.fromJson(document.first); } diff --git a/lib/core/features/profile/presentation/onboarding/onboarding_screen.dart b/lib/core/features/profile/presentation/onboarding/onboarding_screen.dart index cfe8ac3..cf21541 100644 --- a/lib/core/features/profile/presentation/onboarding/onboarding_screen.dart +++ b/lib/core/features/profile/presentation/onboarding/onboarding_screen.dart @@ -163,46 +163,13 @@ class _OnboardingScreenState extends ConsumerState { // ), // ), const SizedBox(height: AnyStepSpacing.md16), - AnyStepTextField( - name: 'street', - labelText: loc.streetAddress, - validator: FormBuilderValidators.street(), - ), - AnyStepTextField( - name: 'streetSecondary', - labelText: loc.streetAddress2, - validator: FormBuilderValidators.street(checkNullOrEmpty: false), - ), - Row( - children: [ - Flexible( - flex: 4, - child: AnyStepTextField( - name: 'city', - labelText: loc.city, - validator: FormBuilderValidators.city(), - ), - ), - const SizedBox(width: AnyStepSpacing.sm2), - Flexible( - flex: 2, - child: AnyStepTextField( - name: 'state', - labelText: loc.state, - validator: FormBuilderValidators.state(), - ), - ), - const SizedBox(width: AnyStepSpacing.sm2), - Flexible( - flex: 3, - child: AnyStepTextField( - name: 'zipCode', - labelText: loc.postalCode, - keyboardType: TextInputType.number, - validator: FormBuilderValidators.zipCode(), - ), - ), - ], + AnyStepAddressField( + formKey: _formKey, + postalCodeFieldName: 'zipCode', + streetSecondaryLabelText: loc.streetAddress2, + isUserAddress: true, + includeEventAddresses: false, + includeUserAddresses: true, ), const SizedBox(height: AnyStepSpacing.md16), diff --git a/lib/core/features/profile/presentation/onboarding/onboarding_screen_controller.dart b/lib/core/features/profile/presentation/onboarding/onboarding_screen_controller.dart index c004da5..28e38ff 100644 --- a/lib/core/features/profile/presentation/onboarding/onboarding_screen_controller.dart +++ b/lib/core/features/profile/presentation/onboarding/onboarding_screen_controller.dart @@ -4,7 +4,6 @@ import 'package:anystep/core/config/posthog/posthog_manager.dart'; import 'package:anystep/core/features/auth/data/auth_repository.dart'; import 'package:anystep/core/features/profile/data/current_user.dart'; import 'package:anystep/core/features/profile/domain/user_model.dart'; -import 'package:anystep/core/features/location/domain/address_model.dart'; import 'package:anystep/core/features/profile/domain/user_role.dart'; import 'package:anystep/core/features/profile/data/user_repository.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -23,20 +22,11 @@ class OnboardingScreenController extends _$OnboardingScreenController { final authRepo = ref.read(authRepositoryProvider); Log.d(authRepo.userId ?? 'No session found'); Log.d('Fetching user details for Email: ${authRepo.user?.email}'); - final address = AddressModel.withGeohash( - street: values['street'], - streetSecondary: values['streetSecondary'], - city: values['city'], - state: values['state'], - country: "US", // Assuming country is always USA for simplicity - postalCode: values['zipCode'], - isUserAddress: true, - ); + final addressId = values['addressId']; final user = UserModel( id: authState!.uid, email: authState.email, - addressId: address.id, - address: address, + addressId: addressId is int ? addressId : int.tryParse(addressId?.toString() ?? ''), firstName: values['firstName'], lastName: values['lastName'], ageGroup: values['ageGroup'], diff --git a/lib/core/features/profile/presentation/onboarding/onboarding_screen_controller.g.dart b/lib/core/features/profile/presentation/onboarding/onboarding_screen_controller.g.dart index 557e836..c655108 100644 --- a/lib/core/features/profile/presentation/onboarding/onboarding_screen_controller.g.dart +++ b/lib/core/features/profile/presentation/onboarding/onboarding_screen_controller.g.dart @@ -35,7 +35,7 @@ final class OnboardingScreenControllerProvider } String _$onboardingScreenControllerHash() => - r'80ae86778cb70412b56da544583dcebe9c01d84a'; + r'abcf3fd725b579ae6a2a9e52fe266d7434aedbb7'; abstract class _$OnboardingScreenController extends $AsyncNotifier { FutureOr build(); diff --git a/lib/core/features/profile/presentation/profile/profile_form.dart b/lib/core/features/profile/presentation/profile/profile_form.dart index c44bbeb..aaf4f63 100644 --- a/lib/core/features/profile/presentation/profile/profile_form.dart +++ b/lib/core/features/profile/presentation/profile/profile_form.dart @@ -79,50 +79,12 @@ class _ProfileFormState extends ConsumerState { validator: FormBuilderValidators.required(), ), const SizedBox(height: AnyStepSpacing.sm8), - AnyStepTextField( - name: 'street', - initialValue: widget.user.address?.street, - labelText: loc.streetAddress, - validator: FormBuilderValidators.required(), - ), - AnyStepTextField( - name: 'streetSecondary', - initialValue: widget.user.address?.streetSecondary, - labelText: loc.apartmentSuiteOptional, - ), - Row( - children: [ - Flexible( - flex: 4, - child: AnyStepTextField( - name: 'city', - initialValue: widget.user.address?.city, - labelText: loc.city, - validator: FormBuilderValidators.city(), - ), - ), - const SizedBox(width: AnyStepSpacing.sm2), - Flexible( - flex: 2, - child: AnyStepTextField( - name: 'state', - initialValue: widget.user.address?.state, - labelText: loc.state, - validator: FormBuilderValidators.state(), - ), - ), - const SizedBox(width: AnyStepSpacing.sm2), - Flexible( - flex: 3, - child: AnyStepTextField( - name: 'postalCode', - initialValue: widget.user.address?.postalCode, - labelText: loc.postalCode, - keyboardType: TextInputType.number, - validator: FormBuilderValidators.zipCode(), - ), - ), - ], + AnyStepAddressField( + formKey: _formKey, + initialAddressId: widget.user.addressId ?? widget.user.address?.id, + isUserAddress: true, + includeEventAddresses: false, + includeUserAddresses: true, ), const SizedBox(height: AnyStepSpacing.md24), if (state.isLoading) const AnyStepLoadingIndicator(), diff --git a/lib/core/features/profile/presentation/profile/profile_screen_controller.dart b/lib/core/features/profile/presentation/profile/profile_screen_controller.dart index 524e6bd..3580142 100644 --- a/lib/core/features/profile/presentation/profile/profile_screen_controller.dart +++ b/lib/core/features/profile/presentation/profile/profile_screen_controller.dart @@ -1,7 +1,6 @@ import 'package:anystep/core/common/utils/log_utils.dart'; import 'package:anystep/core/config/posthog/posthog_manager.dart'; import 'package:anystep/core/features/auth/data/auth_repository.dart'; -import 'package:anystep/core/features/location/domain/address_model.dart'; import 'package:anystep/core/features/profile/data/current_user.dart'; import 'package:anystep/core/features/profile/data/user_repository.dart'; import 'package:anystep/core/features/profile/presentation/profile/profile_screen_controller_state.dart'; @@ -29,20 +28,15 @@ class ProfileScreenController extends _$ProfileScreenController { } final currentUser = ref.read(currentUserStreamProvider).requireValue!; + final addressIdRaw = values['addressId']; + final addressId = + addressIdRaw is int ? addressIdRaw : int.tryParse(addressIdRaw?.toString() ?? ''); final user = currentUser.copyWith( id: authState.uid, firstName: values['firstName'], lastName: values['lastName'], phoneNumber: values['phoneNumber'], - address: AddressModel( - street: values['street'], - streetSecondary: values['streetSecondary'], - city: values['city'], - state: values['state'], - country: "US", - postalCode: values['postalCode'], - isUserAddress: true, - ), + addressId: addressId, ageGroup: values['ageGroup'], ); await ref.read(userRepositoryProvider).createOrUpdate(obj: user, documentId: authState.uid); diff --git a/lib/env/env.dart b/lib/env/env.dart index 5600f53..35425e4 100644 --- a/lib/env/env.dart +++ b/lib/env/env.dart @@ -4,6 +4,7 @@ class Env { static const String posthogApiKey = String.fromEnvironment('POSTHOG_API_KEY'); static const String firebaseVapidKey = String.fromEnvironment('FIREBASE_VAPID_KEY'); + static const String placesApiKey = String.fromEnvironment('GOOGLE_PLACES_API_KEY'); // Collections (don't need to be secret) static const String eventCollectionId = "events"; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9e66cfe..79d2da3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -149,6 +149,12 @@ "@eventFeed": {"description": "Event feed title"}, "search": "Search", "@search": {"description": "Generic label for search"}, + "searchAddress": "Search address", + "@searchAddress": {"description": "Label for address autocomplete search field"}, + "startTypingAddress": "Start typing an address", + "@startTypingAddress": {"description": "Hint text for address autocomplete search field"}, + "noMatchesFound": "No matches found", + "@noMatchesFound": {"description": "Shown when address autocomplete returns no results"}, "searchEvents": "Search events", "@searchEvents": {"description": "Search bar hint text for events"}, "cancel": "Cancel", @@ -395,6 +401,8 @@ "@maxVolunteersOptional": {"description": "Label for max volunteers field"}, "registrationDeadlineOptional": "Registration Deadline (optional)", "@registrationDeadlineOptional": {"description": "Label for registration deadline field"}, + "registrationDeadline": "Registration Deadline", + "@registrationDeadline": {"description": "Label for registration deadline display"}, "externalLinkOptional": "External Link (optional)", "@externalLinkOptional": {"description": "Label for external link field"}, "externalLink": "External Link", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index bed0b39..0a4b918 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -149,6 +149,12 @@ "@eventFeed": {"description": "Título del feed de eventos"}, "search": "Buscar", "@search": {"description": "Para buscador"}, + "searchAddress": "Buscar dirección", + "@searchAddress": {"description": "Etiqueta del campo de búsqueda de direcciones"}, + "startTypingAddress": "Comienza a escribir una dirección", + "@startTypingAddress": {"description": "Texto de sugerencia para el campo de búsqueda de direcciones"}, + "noMatchesFound": "No se encontraron coincidencias", + "@noMatchesFound": {"description": "Se muestra cuando la búsqueda de direcciones no devuelve resultados"}, "searchEvents": "Buscar eventos", "@searchEvents": {"description": "Texto de sugerencia del buscador de eventos"}, "cancel": "Cancelar", @@ -397,6 +403,8 @@ "@maxVolunteersOptional": {"description": "Etiqueta para el campo de máximo de voluntarios"}, "registrationDeadlineOptional": "Fecha límite de registro (opcional)", "@registrationDeadlineOptional": {"description": "Etiqueta para el campo de fecha límite de registro"}, + "registrationDeadline": "Fecha límite de registro", + "@registrationDeadline": {"description": "Etiqueta para mostrar la fecha límite de registro"}, "externalLinkOptional": "Enlace externo (opcional)", "@externalLinkOptional": {"description": "Etiqueta para el campo de enlace externo"}, "externalLink": "Enlace externo", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index d020740..380bf76 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -446,6 +446,24 @@ abstract class AppLocalizations { /// **'Search'** String get search; + /// Label for address autocomplete search field + /// + /// In en, this message translates to: + /// **'Search address'** + String get searchAddress; + + /// Hint text for address autocomplete search field + /// + /// In en, this message translates to: + /// **'Start typing an address'** + String get startTypingAddress; + + /// Shown when address autocomplete returns no results + /// + /// In en, this message translates to: + /// **'No matches found'** + String get noMatchesFound; + /// Search bar hint text for events /// /// In en, this message translates to: @@ -1118,6 +1136,12 @@ abstract class AppLocalizations { /// **'Registration Deadline (optional)'** String get registrationDeadlineOptional; + /// Label for registration deadline display + /// + /// In en, this message translates to: + /// **'Registration Deadline'** + String get registrationDeadline; + /// Label for external link field /// /// 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 66f742b..8637716 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -202,6 +202,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get search => 'Search'; + @override + String get searchAddress => 'Search address'; + + @override + String get startTypingAddress => 'Start typing an address'; + + @override + String get noMatchesFound => 'No matches found'; + @override String get searchEvents => 'Search events'; @@ -553,6 +562,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get registrationDeadlineOptional => 'Registration Deadline (optional)'; + @override + String get registrationDeadline => 'Registration Deadline'; + @override String get externalLinkOptional => 'External Link (optional)'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 3543659..f8ce0f0 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -204,6 +204,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get search => 'Buscar'; + @override + String get searchAddress => 'Buscar dirección'; + + @override + String get startTypingAddress => 'Comienza a escribir una dirección'; + + @override + String get noMatchesFound => 'No se encontraron coincidencias'; + @override String get searchEvents => 'Buscar eventos'; @@ -560,6 +569,9 @@ class AppLocalizationsEs extends AppLocalizations { String get registrationDeadlineOptional => 'Fecha límite de registro (opcional)'; + @override + String get registrationDeadline => 'Fecha límite de registro'; + @override String get externalLinkOptional => 'Enlace externo (opcional)'; From 2f622a4ac105345368d75146a7ec9d32302969ab Mon Sep 17 00:00:00 2001 From: alexdivadi Date: Sat, 14 Feb 2026 18:47:18 -0600 Subject: [PATCH 3/7] fix: address selector --- .../inputs/address_autocomplete_field.dart | 1 + .../inputs/any_step_address_field.dart | 70 +++++++++++-------- .../event_detail_form_controller.dart | 14 ++-- .../location/data/address_repository.dart | 5 ++ 4 files changed, 53 insertions(+), 37 deletions(-) diff --git a/lib/core/common/widgets/inputs/address_autocomplete_field.dart b/lib/core/common/widgets/inputs/address_autocomplete_field.dart index 828c92e..ba738e9 100644 --- a/lib/core/common/widgets/inputs/address_autocomplete_field.dart +++ b/lib/core/common/widgets/inputs/address_autocomplete_field.dart @@ -117,6 +117,7 @@ class _AddressAutocompleteFieldState extends ConsumerState? onAddressSaved; + final bool showSaveButton; final String? streetLabelText; final String? streetSecondaryLabelText; final String? cityLabelText; @@ -87,6 +90,7 @@ class _AnyStepAddressFieldState extends ConsumerState { bool _isLoading = false; bool _isSearchDisabled = false; bool _isApplyingSelection = false; + bool _isSaving = false; List _dbResults = []; List _predictions = []; String? _error; @@ -94,16 +98,15 @@ class _AnyStepAddressFieldState extends ConsumerState { String? _placeName; double? _latitude; double? _longitude; + int? _addressId; @override void initState() { super.initState(); _streetController.text = widget.initialStreet ?? ''; _streetFocusNode.addListener(_handleFocusChange); - _cityFocusNode.addListener(_handleFieldBlur); - _stateFocusNode.addListener(_handleFieldBlur); - _postalCodeFocusNode.addListener(_handleFieldBlur); _isSearchDisabled = widget.disableSearch || Env.placesApiKey.isEmpty; + _addressId = widget.initialAddressId; if (widget.initialAddressId != null) { WidgetsBinding.instance.addPostFrameCallback((_) => _loadInitialAddress()); } @@ -125,18 +128,14 @@ class _AnyStepAddressFieldState extends ConsumerState { _streetController.dispose(); _streetFocusNode.removeListener(_handleFocusChange); _streetFocusNode.dispose(); - _cityFocusNode.removeListener(_handleFieldBlur); _cityFocusNode.dispose(); - _stateFocusNode.removeListener(_handleFieldBlur); _stateFocusNode.dispose(); - _postalCodeFocusNode.removeListener(_handleFieldBlur); _postalCodeFocusNode.dispose(); super.dispose(); } void _handleFocusChange() { if (!_streetFocusNode.hasFocus) { - _maybeSaveAddress(); setState(() { _dbResults = []; _predictions = []; @@ -145,12 +144,6 @@ class _AnyStepAddressFieldState extends ConsumerState { } } - void _handleFieldBlur() { - if (!_cityFocusNode.hasFocus && !_stateFocusNode.hasFocus && !_postalCodeFocusNode.hasFocus) { - _maybeSaveAddress(); - } - } - Future> _searchDb(String query) async { if (!widget.includeEventAddresses && !widget.includeUserAddresses) return []; final repo = ref.read(addressRepositoryProvider); @@ -169,6 +162,7 @@ class _AnyStepAddressFieldState extends ConsumerState { _placeName = null; _latitude = null; _longitude = null; + _addressId = null; form.fields[widget.addressIdFieldName]?.didChange(null); widget.onAddressSaved?.call(null); } @@ -235,11 +229,13 @@ class _AnyStepAddressFieldState extends ConsumerState { _placeName = address.name ?? address.formattedAddress; _latitude = address.latitude; _longitude = address.longitude; + _addressId = address.id; form.fields[widget.addressIdFieldName]?.didChange(address.id); widget.onAddressSaved?.call(address.id); _streetController.text = address.street; - _streetController.selection = - TextSelection.fromPosition(TextPosition(offset: _streetController.text.length)); + _streetController.selection = TextSelection.fromPosition( + TextPosition(offset: _streetController.text.length), + ); _streetFocusNode.unfocus(); _isApplyingSelection = false; setState(() { @@ -269,11 +265,11 @@ class _AnyStepAddressFieldState extends ConsumerState { _longitude = parsed.longitude; _isApplyingSelection = true; _streetController.text = parsed.street.isNotEmpty ? parsed.street : prediction.description; - _streetController.selection = - TextSelection.fromPosition(TextPosition(offset: _streetController.text.length)); + _streetController.selection = TextSelection.fromPosition( + TextPosition(offset: _streetController.text.length), + ); _streetFocusNode.unfocus(); _isApplyingSelection = false; - await _saveAddressIfComplete(); setState(() { _dbResults = []; _predictions = []; @@ -311,11 +307,6 @@ class _AnyStepAddressFieldState extends ConsumerState { } } - void _maybeSaveAddress() { - if (_isApplyingSelection) return; - _saveAddressIfComplete(); - } - Future _saveAddressIfComplete() async { final form = widget.formKey.currentState; if (form == null) return; @@ -338,12 +329,16 @@ class _AnyStepAddressFieldState extends ConsumerState { name: _placeName, ); try { + setState(() => _isSaving = true); final repo = ref.read(addressRepositoryProvider); - final saved = await repo.createOrUpdate(obj: address); + final saved = await repo.createOrUpdate(obj: address, documentId: _addressId?.toString()); + _addressId = saved.id; form.fields[widget.addressIdFieldName]?.didChange(saved.id); widget.onAddressSaved?.call(saved.id); } catch (e, stackTrace) { Log.e('Error saving address', e, stackTrace); + } finally { + if (mounted) setState(() => _isSaving = false); } } @@ -372,10 +367,7 @@ class _AnyStepAddressFieldState extends ConsumerState { if (_error != null && !_isSearchDisabled) Padding( padding: const EdgeInsets.only(bottom: AnyStepSpacing.sm4), - child: Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), + child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), ), if (_dbResults.isEmpty && _predictions.isEmpty && @@ -407,8 +399,9 @@ class _AnyStepAddressFieldState extends ConsumerState { final prediction = _predictions[index - _dbResults.length]; return ListTile( title: Text(prediction.mainText ?? prediction.description), - subtitle: - prediction.secondaryText != null ? Text(prediction.secondaryText!) : null, + subtitle: prediction.secondaryText != null + ? Text(prediction.secondaryText!) + : null, onTap: () => _selectPrediction(prediction), ); }, @@ -464,6 +457,23 @@ class _AnyStepAddressFieldState extends ConsumerState { ), ], ), + if (widget.showSaveButton) + Padding( + padding: const EdgeInsets.only(top: AnyStepSpacing.sm8), + child: Align( + alignment: Alignment.centerRight, + child: ElevatedButton( + onPressed: _isSaving ? null : _saveAddressIfComplete, + child: _isSaving + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ) + : Text(loc.save), + ), + ), + ), ], ); } diff --git a/lib/core/features/events/presentation/event_detail/event_detail_form_controller.dart b/lib/core/features/events/presentation/event_detail/event_detail_form_controller.dart index 62adfcb..423d11d 100644 --- a/lib/core/features/events/presentation/event_detail/event_detail_form_controller.dart +++ b/lib/core/features/events/presentation/event_detail/event_detail_form_controller.dart @@ -22,13 +22,13 @@ class EventDetailFormController extends _$EventDetailFormController { ? null : int.tryParse(maxVolunteersRaw.toString()); final externalLinkRaw = values['externalLink']; - final externalLink = - (externalLinkRaw == null || externalLinkRaw.toString().trim().isEmpty) - ? null - : externalLinkRaw.toString(); + final externalLink = (externalLinkRaw == null || externalLinkRaw.toString().trim().isEmpty) + ? null + : externalLinkRaw.toString(); final addressIdRaw = values['addressId']; - final addressId = - addressIdRaw is int ? addressIdRaw : int.tryParse(addressIdRaw?.toString() ?? ''); + final addressId = addressIdRaw is int + ? addressIdRaw + : int.tryParse(addressIdRaw?.toString() ?? ''); final event = EventModel( id: state.eventId, @@ -58,7 +58,7 @@ class EventDetailFormController extends _$EventDetailFormController { PostHogManager.capture( state.eventId == null ? 'event_created' : 'event_updated', - properties: { + properties: { 'event_id': state.eventId ?? '', 'name': event.name, 'start_time': event.startTime.toIso8601String(), diff --git a/lib/core/features/location/data/address_repository.dart b/lib/core/features/location/data/address_repository.dart index 7e014f0..2011b11 100644 --- a/lib/core/features/location/data/address_repository.dart +++ b/lib/core/features/location/data/address_repository.dart @@ -61,6 +61,11 @@ class AddressRepository implements IRepository { @override Future createOrUpdate({required AddressModel obj, String? documentId}) async { final normalized = _normalizeAddress(obj); + if (documentId != null && documentId.toString().isNotEmpty) { + final data = normalized.toJson()..['id'] = documentId; + final updated = await database.createOrUpdate(table: collectionId, data: data); + return AddressModel.fromJson(updated.first); + } List> matches = []; if (normalized.placeId != null && normalized.placeId!.trim().isNotEmpty) { From 5d8dc81309774898c03af3fd602bb20dadb65768 Mon Sep 17 00:00:00 2001 From: alexdivadi Date: Sat, 14 Feb 2026 18:51:55 -0600 Subject: [PATCH 4/7] fix: address dedupe --- lib/core/config/theme/theme.dart | 2 +- lib/core/features/settings/presentation/settings_screen.dart | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/core/config/theme/theme.dart b/lib/core/config/theme/theme.dart index 9f95da0..5887da9 100644 --- a/lib/core/config/theme/theme.dart +++ b/lib/core/config/theme/theme.dart @@ -5,7 +5,7 @@ import 'text_styles.dart'; class AnyStepTheme { static const double _textScaleFactor = 0.94; - static const double _textHeightFactor = 0.95; + static const double _textHeightFactor = 0.98; static TextTheme _tightTextTheme(Color textColor) { return AnyStepTextStyles.textTheme diff --git a/lib/core/features/settings/presentation/settings_screen.dart b/lib/core/features/settings/presentation/settings_screen.dart index f61d95c..df9c109 100644 --- a/lib/core/features/settings/presentation/settings_screen.dart +++ b/lib/core/features/settings/presentation/settings_screen.dart @@ -43,6 +43,7 @@ class SettingsScreen extends ConsumerWidget { ListTile( leading: const Icon(Icons.account_circle), title: Text(loc.accountSettings), + trailing: const Icon(Icons.chevron_right), onTap: () => context.push(ProfileScreen.path), ), ListTile( From fff33abd5e3cd3c8eb9830169c6fa2f7b347e749 Mon Sep 17 00:00:00 2001 From: alexdivadi Date: Sat, 14 Feb 2026 18:56:17 -0600 Subject: [PATCH 5/7] fix: don't initialize name field --- .../inputs/any_step_address_field.dart | 3 +- .../location/data/address_repository.dart | 30 ++++++------------- 2 files changed, 11 insertions(+), 22 deletions(-) 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 9c0ac4d..4e75395 100644 --- a/lib/core/common/widgets/inputs/any_step_address_field.dart +++ b/lib/core/common/widgets/inputs/any_step_address_field.dart @@ -292,7 +292,8 @@ class _AnyStepAddressFieldState extends ConsumerState { final repo = ref.read(addressRepositoryProvider); final address = await repo.get(documentId: widget.initialAddressId.toString()); _placeId = address.placeId; - _placeName = address.name ?? address.formattedAddress; + // TODO: add name field to form + _placeName = address.name; _latitude = address.latitude; _longitude = address.longitude; form.fields['street']?.didChange(address.street); diff --git a/lib/core/features/location/data/address_repository.dart b/lib/core/features/location/data/address_repository.dart index 2011b11..144defa 100644 --- a/lib/core/features/location/data/address_repository.dart +++ b/lib/core/features/location/data/address_repository.dart @@ -29,23 +29,12 @@ class AddressRepository implements IRepository { } final results = {}; - final searchColumns = [ - 'street', - 'street_secondary', - 'city', - 'state', - 'postal_code', - 'name', - ]; + final searchColumns = ['street', 'street_secondary', 'city', 'state', 'postal_code', 'name']; final likeValue = "%$trimmed%"; for (final column in searchColumns) { final filters = [...baseFilters, AnyStepFilter.like(column, likeValue)]; - final documents = await database.list( - table: collectionId, - filters: filters, - limit: limit, - ); + final documents = await database.list(table: collectionId, filters: filters, limit: limit); for (final doc in documents) { final model = AddressModel.fromJson(doc); final key = model.id?.toString() ?? model.formattedAddress; @@ -83,10 +72,10 @@ class AddressRepository implements IRepository { matches = await database.list( table: collectionId, filters: [ - AnyStepFilter.equals('street', normalized.street), - AnyStepFilter.equals('street_secondary', normalized.streetSecondary), - AnyStepFilter.equals('city', normalized.city), - AnyStepFilter.equals('state', normalized.state), + AnyStepFilter.like('street', normalized.street), + AnyStepFilter.like('street_secondary', normalized.streetSecondary), + AnyStepFilter.like('city', normalized.city), + AnyStepFilter.like('state', normalized.state), AnyStepFilter.equals('postal_code', normalized.postalCode), AnyStepFilter.equals('country', normalized.country), AnyStepFilter.equals('is_user_address', normalized.isUserAddress), @@ -144,10 +133,9 @@ String _collapseWhitespace(String value) { AddressModel _normalizeAddress(AddressModel address) { final street = _collapseWhitespace(address.street); - final streetSecondary = - address.streetSecondary == null || address.streetSecondary!.trim().isEmpty - ? null - : _collapseWhitespace(address.streetSecondary!); + final streetSecondary = address.streetSecondary == null || address.streetSecondary!.trim().isEmpty + ? null + : _collapseWhitespace(address.streetSecondary!); final city = _collapseWhitespace(address.city); final state = _collapseWhitespace(address.state).toUpperCase(); final postalCode = _collapseWhitespace(address.postalCode); From d382a2c71d63a67a7f6596659722ce3a1499ea81 Mon Sep 17 00:00:00 2001 From: alexdivadi Date: Sat, 14 Feb 2026 18:59:52 -0600 Subject: [PATCH 6/7] fix: posthog type enforcement --- lib/core/common/widgets/share_button.dart | 2 +- lib/core/config/theme/theme_mode.dart | 2 +- .../events/utils/launch_calendar.dart | 8 ++--- .../features/location/utils/launch_map.dart | 27 ++++++++-------- .../onboarding_screen_controller.dart | 5 ++- .../profile/profile_screen_controller.dart | 7 +++-- .../reports/presentation/reports_screen.dart | 31 +++++++++---------- .../attendee_search_form_controller.dart | 6 ++-- .../sign_up_button_controller.dart | 7 +++-- 9 files changed, 49 insertions(+), 46 deletions(-) diff --git a/lib/core/common/widgets/share_button.dart b/lib/core/common/widgets/share_button.dart index 62a2242..2956396 100644 --- a/lib/core/common/widgets/share_button.dart +++ b/lib/core/common/widgets/share_button.dart @@ -9,7 +9,7 @@ Future shareContent({required String content, String? title, List? PostHogManager.capture( 'content_shared', - properties: { + properties: { if (title != null) 'title': title, 'has_files': files != null && files.isNotEmpty, 'status': result.status.name, diff --git a/lib/core/config/theme/theme_mode.dart b/lib/core/config/theme/theme_mode.dart index 890667c..2e9d44b 100644 --- a/lib/core/config/theme/theme_mode.dart +++ b/lib/core/config/theme/theme_mode.dart @@ -21,7 +21,7 @@ class ThemeModeController extends _$ThemeModeController { Future setThemeMode(ThemeMode mode) async { final prefs = ref.watch(appPreferencesProvider).requireValue; await prefs.setThemeMode(mode.name); - PostHogManager.capture('theme_mode_changed', properties: {'mode': mode.name}); + PostHogManager.capture('theme_mode_changed', properties: {'mode': mode.name}); ref.invalidateSelf(); } } diff --git a/lib/core/features/events/utils/launch_calendar.dart b/lib/core/features/events/utils/launch_calendar.dart index a9618ec..24c2f3e 100644 --- a/lib/core/features/events/utils/launch_calendar.dart +++ b/lib/core/features/events/utils/launch_calendar.dart @@ -10,11 +10,7 @@ String _formatUtcForCalendar(DateTime dateTime) { Uri buildGoogleCalendarUrl(EventModel event) { final start = _formatUtcForCalendar(event.startTime); final end = _formatUtcForCalendar(event.endTime); - final params = { - 'action': 'TEMPLATE', - 'text': event.name, - 'dates': '$start/$end', - }; + final params = {'action': 'TEMPLATE', 'text': event.name, 'dates': '$start/$end'}; final description = (event.description ?? '').trim(); if (description.isNotEmpty) { params['details'] = description; @@ -31,7 +27,7 @@ Future openGoogleCalendar(EventModel event) async { PostHogManager.capture( 'calendar_add_clicked', - properties: { + properties: { 'event_id': event.id ?? '', 'start_time': event.startTime.toIso8601String(), 'end_time': event.endTime.toIso8601String(), diff --git a/lib/core/features/location/utils/launch_map.dart b/lib/core/features/location/utils/launch_map.dart index d238dac..23163fe 100644 --- a/lib/core/features/location/utils/launch_map.dart +++ b/lib/core/features/location/utils/launch_map.dart @@ -3,25 +3,26 @@ import 'package:anystep/core/features/location/domain/address_model.dart'; import 'package:url_launcher/url_launcher.dart'; String buildAddressQuery(AddressModel address) { - final parts = - [ - address.street, - if ((address.streetSecondary ?? '').isNotEmpty) address.streetSecondary, - address.city, - address.state, - address.postalCode, - ].where((part) => part != null && part.trim().isNotEmpty).toList(); + final parts = [ + address.street, + if ((address.streetSecondary ?? '').isNotEmpty) address.streetSecondary, + address.city, + address.state, + address.postalCode, + ].where((part) => part != null && part.trim().isNotEmpty).toList(); return Uri.encodeComponent(parts.join(', ')); } Future openMap(AddressModel address) async { - final query = - address.latitude != null && address.longitude != null - ? Uri.encodeComponent('${address.latitude}, ${address.longitude}') - : buildAddressQuery(address); + final query = address.latitude != null && address.longitude != null + ? Uri.encodeComponent('${address.latitude}, ${address.longitude}') + : buildAddressQuery(address); final googleMapsUrl = Uri.parse('https://www.google.com/maps/search/?api=1&query=$query'); - PostHogManager.capture('map_opened', properties: {'address': address.toJson(), 'query': query}); + PostHogManager.capture( + 'map_opened', + properties: {'address': address.toJson(), 'query': query}, + ); if (await canLaunchUrl(googleMapsUrl)) { await launchUrl(googleMapsUrl); diff --git a/lib/core/features/profile/presentation/onboarding/onboarding_screen_controller.dart b/lib/core/features/profile/presentation/onboarding/onboarding_screen_controller.dart index 28e38ff..a65dec5 100644 --- a/lib/core/features/profile/presentation/onboarding/onboarding_screen_controller.dart +++ b/lib/core/features/profile/presentation/onboarding/onboarding_screen_controller.dart @@ -35,7 +35,10 @@ class OnboardingScreenController extends _$OnboardingScreenController { ); await ref.read(userRepositoryProvider).createOrUpdate(obj: user, documentId: authState.uid); - PostHogManager.capture('user_onboarding_completed', properties: {'user_id': user.id}); + PostHogManager.capture( + 'user_onboarding_completed', + properties: {'user_id': user.id}, + ); }); ref.invalidate(currentUserStreamProvider); } diff --git a/lib/core/features/profile/presentation/profile/profile_screen_controller.dart b/lib/core/features/profile/presentation/profile/profile_screen_controller.dart index 3580142..c656b6f 100644 --- a/lib/core/features/profile/presentation/profile/profile_screen_controller.dart +++ b/lib/core/features/profile/presentation/profile/profile_screen_controller.dart @@ -29,8 +29,9 @@ class ProfileScreenController extends _$ProfileScreenController { final currentUser = ref.read(currentUserStreamProvider).requireValue!; final addressIdRaw = values['addressId']; - final addressId = - addressIdRaw is int ? addressIdRaw : int.tryParse(addressIdRaw?.toString() ?? ''); + final addressId = addressIdRaw is int + ? addressIdRaw + : int.tryParse(addressIdRaw?.toString() ?? ''); final user = currentUser.copyWith( id: authState.uid, firstName: values['firstName'], @@ -41,7 +42,7 @@ class ProfileScreenController extends _$ProfileScreenController { ); await ref.read(userRepositoryProvider).createOrUpdate(obj: user, documentId: authState.uid); - PostHogManager.capture('profile_updated', properties: {'user_id': user.id}); + PostHogManager.capture('profile_updated', properties: {'user_id': user.id}); state = state.copyWith(isLoading: false, error: null); } on AuthApiException catch (e) { diff --git a/lib/core/features/reports/presentation/reports_screen.dart b/lib/core/features/reports/presentation/reports_screen.dart index bfc2bd1..1b2b887 100644 --- a/lib/core/features/reports/presentation/reports_screen.dart +++ b/lib/core/features/reports/presentation/reports_screen.dart @@ -104,7 +104,7 @@ class _ReportsScreenState extends ConsumerState { ); PostHogManager.capture( 'report_exported', - properties: { + properties: { 'dateRange': '${_dateFmt.format(_start)} → ${_dateFmt.format(_end)}', 'custom': _custom, 'filename': filename, @@ -134,12 +134,11 @@ class _ReportsScreenState extends ConsumerState { title: Text(loc.reportsTitle), actions: [ asyncReports.when( - data: - (reports) => IconButton( - tooltip: loc.exportCsv, - onPressed: reports.isEmpty ? null : () => _exportCsv(reports), - icon: const Icon(Icons.download), - ), + data: (reports) => IconButton( + tooltip: loc.exportCsv, + onPressed: reports.isEmpty ? null : () => _exportCsv(reports), + icon: const Icon(Icons.download), + ), loading: () => IconButton(onPressed: null, icon: const Icon(Icons.download)), error: (e, st) => IconButton(onPressed: null, icon: const Icon(Icons.error_outline)), ), @@ -211,16 +210,14 @@ class _ReportsScreenState extends ConsumerState { ], ); }, - loading: - () => const Padding( - padding: EdgeInsets.symmetric(vertical: 48.0), - child: Center(child: AnyStepLoadingIndicator()), - ), - error: - (e, st) => Padding( - padding: const EdgeInsets.symmetric(vertical: 48.0), - child: Center(child: Text(loc.errorLoadingReports('$e'))), - ), + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 48.0), + child: Center(child: AnyStepLoadingIndicator()), + ), + error: (e, st) => Padding( + padding: const EdgeInsets.symmetric(vertical: 48.0), + child: Center(child: Text(loc.errorLoadingReports('$e'))), + ), ), ), const SizedBox(height: 24), diff --git a/lib/core/features/user_events/presentation/attendee_search_form_controller.dart b/lib/core/features/user_events/presentation/attendee_search_form_controller.dart index 3e060c0..a403572 100644 --- a/lib/core/features/user_events/presentation/attendee_search_form_controller.dart +++ b/lib/core/features/user_events/presentation/attendee_search_form_controller.dart @@ -21,7 +21,9 @@ class AttendeeSearchFormController extends _$AttendeeSearchFormController { if (res.items.isEmpty) { await ref .read(userEventRepositoryProvider) - .createOrUpdate(obj: UserEventModel(userId: user.id, eventId: eventId, attended: true)); + .createOrUpdate( + obj: UserEventModel(userId: user.id, eventId: eventId, attended: true), + ); } else { final userEvent = res.items.first; await ref @@ -34,7 +36,7 @@ class AttendeeSearchFormController extends _$AttendeeSearchFormController { PostHogManager.capture( 'user_toggled_attendance', - properties: { + properties: { 'event_id': eventId, 'user_id': user.id, 'attended': res.items.isEmpty || !(res.items.first.attended), diff --git a/lib/core/features/user_events/presentation/sign_up_button_controller.dart b/lib/core/features/user_events/presentation/sign_up_button_controller.dart index 1eebe4c..e5690b4 100644 --- a/lib/core/features/user_events/presentation/sign_up_button_controller.dart +++ b/lib/core/features/user_events/presentation/sign_up_button_controller.dart @@ -33,7 +33,7 @@ class SignUpButtonController extends _$SignUpButtonController { await ref.read(userEventRepositoryProvider).createOrUpdate(obj: userEvent); PostHogManager.capture( 'user_signed_up', - properties: {'event_id': eventId, 'user_id': authState.uid}, + properties: {'event_id': eventId, 'user_id': authState.uid}, ); }); ref.invalidate(signUpStatusProvider(eventId)); @@ -46,7 +46,10 @@ class SignUpButtonController extends _$SignUpButtonController { await ref.read(userEventRepositoryProvider).delete(userEvent); PostHogManager.capture( 'user_canceled_sign_up', - properties: {'event_id': userEvent.eventId ?? '', 'user_id': userEvent.userId ?? ''}, + properties: { + 'event_id': userEvent.eventId ?? '', + 'user_id': userEvent.userId ?? '', + }, ); }); ref.invalidate(signUpStatusProvider(userEvent.eventId!)); From d8cb141362d238644efc95f74b0e687830fc61cf Mon Sep 17 00:00:00 2001 From: alexdivadi Date: Sat, 14 Feb 2026 19:26:39 -0600 Subject: [PATCH 7/7] feat: about page --- lib/core/common/widgets/dropdown_section.dart | 8 +- lib/core/config/router/routes.dart | 5 + .../widgets/dashboard_metrics_card.dart | 2 +- .../event_detail/event_detail_info.dart | 6 +- .../settings/presentation/about_page.dart | 89 +++++++++++++++++ .../settings/presentation/screens.dart | 1 + .../presentation/settings_screen.dart | 6 ++ lib/l10n/app_en.arb | 32 +++++++ lib/l10n/app_es.arb | 32 +++++++ lib/l10n/generated/app_localizations.dart | 96 +++++++++++++++++++ lib/l10n/generated/app_localizations_en.dart | 58 +++++++++++ lib/l10n/generated/app_localizations_es.dart | 58 +++++++++++ pubspec.lock | 8 ++ pubspec.yaml | 1 + 14 files changed, 393 insertions(+), 9 deletions(-) create mode 100644 lib/core/features/settings/presentation/about_page.dart diff --git a/lib/core/common/widgets/dropdown_section.dart b/lib/core/common/widgets/dropdown_section.dart index 1839851..c0df20e 100644 --- a/lib/core/common/widgets/dropdown_section.dart +++ b/lib/core/common/widgets/dropdown_section.dart @@ -1,8 +1,8 @@ import 'package:anystep/core/common/constants/spacing.dart'; import 'package:flutter/material.dart'; -class DropdownSection extends StatefulWidget { - const DropdownSection({ +class DropdownText extends StatefulWidget { + const DropdownText({ super.key, required this.title, required this.content, @@ -16,10 +16,10 @@ class DropdownSection extends StatefulWidget { final EdgeInsetsGeometry padding; @override - State createState() => _DropdownSectionState(); + State createState() => _DropdownTextState(); } -class _DropdownSectionState extends State { +class _DropdownTextState extends State { bool _expanded = false; @override diff --git a/lib/core/config/router/routes.dart b/lib/core/config/router/routes.dart index 0a7c16f..6d4db66 100644 --- a/lib/core/config/router/routes.dart +++ b/lib/core/config/router/routes.dart @@ -225,6 +225,11 @@ final routes = [ name: ProfileScreen.name, builder: (context, state) => const ProfileScreen(), ), + GoRoute( + path: AboutPage.path, + name: AboutPage.name, + builder: (context, state) => const AboutPage(), + ), GoRoute( path: NotificationSettingsPage.path, name: NotificationSettingsPage.name, diff --git a/lib/core/features/dashboard/presentation/widgets/dashboard_metrics_card.dart b/lib/core/features/dashboard/presentation/widgets/dashboard_metrics_card.dart index 63f01ab..8bdaf33 100644 --- a/lib/core/features/dashboard/presentation/widgets/dashboard_metrics_card.dart +++ b/lib/core/features/dashboard/presentation/widgets/dashboard_metrics_card.dart @@ -216,7 +216,7 @@ class _HeroMetric extends StatelessWidget { height: 42, child: Align( alignment: Alignment.centerLeft, - child: AnyStepLoadingIndicator(size: 22), + child: CircularProgressIndicator.adaptive(), ), ) : Column( diff --git a/lib/core/features/events/presentation/event_detail/event_detail_info.dart b/lib/core/features/events/presentation/event_detail/event_detail_info.dart index 425b904..f147e91 100644 --- a/lib/core/features/events/presentation/event_detail/event_detail_info.dart +++ b/lib/core/features/events/presentation/event_detail/event_detail_info.dart @@ -113,7 +113,7 @@ class EventDetailInfo extends StatelessWidget { ], ), ), - DropdownSection( + DropdownText( title: Text(loc.description, style: Theme.of(context).textTheme.titleMedium), content: event.description ?? loc.noDescription, maxLines: 2, @@ -155,9 +155,7 @@ class EventDetailInfo extends StatelessWidget { leading: const Icon(Icons.event_busy), title: Text(loc.registrationDeadline), subtitle: Text( - DateFormat('MMM d, yyyy • h:mm a').format( - event.registrationDeadline!.toLocal(), - ), + DateFormat('MMM d, yyyy • h:mm a').format(event.registrationDeadline!.toLocal()), ), ), ], diff --git a/lib/core/features/settings/presentation/about_page.dart b/lib/core/features/settings/presentation/about_page.dart new file mode 100644 index 0000000..17e6c67 --- /dev/null +++ b/lib/core/features/settings/presentation/about_page.dart @@ -0,0 +1,89 @@ +import 'package:anystep/core/common/constants/spacing.dart'; +import 'package:anystep/core/common/utils/log_utils.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}); + + static const path = '/about'; + static const name = 'about'; + + static const _instagramUrl = 'https://instagram.com/anystepcommunity'; + static const _facebookUrl = 'https://www.facebook.com/AnyStepCoaching'; + static const _linkedInUrl = 'https://www.linkedin.com/company/any-step-community-services/'; + static const _twitterUrl = 'https://twitter.com/Anystepcommunit'; + + Future _openLink(String url) async { + final uri = Uri.tryParse(url); + if (uri == null) return; + try { + final launched = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!launched) { + Log.e('Failed to open external link: $uri'); + } + } catch (e) { + Log.e('Error opening external link', e); + } + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + return AnyStepScaffold( + appBar: AnyStepAppBar( + title: Text(loc.aboutTitle), + actions: [ + IconButton( + tooltip: loc.aboutInstagram, + icon: const FaIcon(FontAwesomeIcons.instagram), + onPressed: () => _openLink(_instagramUrl), + ), + IconButton( + tooltip: loc.aboutFacebook, + icon: const FaIcon(FontAwesomeIcons.facebook), + onPressed: () => _openLink(_facebookUrl), + ), + IconButton( + tooltip: loc.aboutLinkedIn, + icon: const FaIcon(FontAwesomeIcons.linkedin), + onPressed: () => _openLink(_linkedInUrl), + ), + IconButton( + tooltip: loc.aboutX, + icon: const FaIcon(FontAwesomeIcons.xTwitter), + onPressed: () => _openLink(_twitterUrl), + ), + ], + ), + 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), + ], + ), + ); + } +} diff --git a/lib/core/features/settings/presentation/screens.dart b/lib/core/features/settings/presentation/screens.dart index 55b3a5c..274e974 100644 --- a/lib/core/features/settings/presentation/screens.dart +++ b/lib/core/features/settings/presentation/screens.dart @@ -1,2 +1,3 @@ export 'settings_screen.dart'; export 'notification_settings_page.dart'; +export 'about_page.dart'; diff --git a/lib/core/features/settings/presentation/settings_screen.dart b/lib/core/features/settings/presentation/settings_screen.dart index df9c109..8d4f23d 100644 --- a/lib/core/features/settings/presentation/settings_screen.dart +++ b/lib/core/features/settings/presentation/settings_screen.dart @@ -31,6 +31,12 @@ class SettingsScreen extends ConsumerWidget { return ListView( padding: const EdgeInsets.all(AnyStepSpacing.md16), children: [ + ListTile( + leading: const Icon(Icons.info_outline), + title: Text(loc.aboutTitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push(AboutPage.path), + ), const ThemeModeSetting(), const LocaleSetting(), ListTile( diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 79d2da3..2aba77a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -340,6 +340,38 @@ "@onboardingImpactDesc": {"description": "Description for onboarding impact page"}, "notificationSettingsTitle": "Notification Settings", "@notificationSettingsTitle": {"description": "Settings label for notifications page and toggle"}, + "aboutTitle": "About", + "@aboutTitle": {"description": "Title for About page and settings link"}, + "aboutDescription": "AnyStep helps volunteers find and track community service opportunities.", + "@aboutDescription": {"description": "Short description shown on About page"}, + "aboutInstagram": "Instagram", + "@aboutInstagram": {"description": "Label for Instagram link"}, + "aboutLinkedIn": "LinkedIn", + "@aboutLinkedIn": {"description": "Label for LinkedIn link"}, + "aboutFacebook": "Facebook", + "@aboutFacebook": {"description": "Label for Facebook link"}, + "aboutX": "X", + "@aboutX": {"description": "Label for X (formerly Twitter) link" }, + "aboutStoryTitle": "Our Story", + "@aboutStoryTitle": {"description": "Title for the expandable About story section"}, + "aboutStoryIntro": "At the heart of Any Step Community Services lies a story of personal conviction and a shared commitment to making a difference. Our founder Sydney and her husband Sherman Diggs draw their passion for alleviating food insecurity from profound personal experiences.", + "@aboutStoryIntro": {"description": "Intro paragraph for About story"}, + "aboutStorySydneyTitle": "Sydney's Story: A Witness to Resilience", + "@aboutStorySydneyTitle": {"description": "Heading for Sydney story"}, + "aboutStorySydneyBody": "Raised in a single-parent home after her parents' divorce, Sydney observed her mother navigate life with unwavering determination. Working tirelessly as a gerontological nurse for over thirty years, Sydney's mother faced the challenge of surviving on meager Social Security benefits. Witnessing her mother's daily struggle ignited Sydney's determination to make a difference. Without the support of Sydney and her husband Sherman, her mother would have faced an even more challenging journey to secure nutritious food.", + "@aboutStorySydneyBody": {"description": "Body paragraph for Sydney story"}, + "aboutStoryShermanTitle": "Sherman's Journey: From Poverty to Advocacy", + "@aboutStoryShermanTitle": {"description": "Heading for Sherman story"}, + "aboutStoryShermanBody": "As the youngest of thirteen, Sherman's upbringing was marked by the hardships of a single-parent household after his parents separated. Personally acquainted with the realities of poverty and hunger, Sherman's life experiences became the driving force behind his unwavering passion for ensuring food equity for all.", + "@aboutStoryShermanBody": {"description": "Body paragraph for Sherman story"}, + "aboutStoryCostaRicaTitle": "A Turning Point: Impactful Journey to Costa Rica", + "@aboutStoryCostaRicaTitle": {"description": "Heading for Costa Rica story"}, + "aboutStoryCostaRicaBody": "In 2016, the Diggs embarked on a transformative journey with their dear friends Michael and Seidy Trent to Costa Rica. Inspired by the experience, Sydney organized a visit to an elder facility in Heredia, Costa Rica. The couple generously donated undergarments, hygienic products, and sandals, recognizing the immense need within the community. The impact of this trip prompted Sydney to return to Costa Rica with even more donations, this time filling the local food pantry.", + "@aboutStoryCostaRicaBody": {"description": "Body paragraph for Costa Rica story"}, + "aboutStoryLocalTitle": "A Local Focus: Bridging the Gap for North Texas Seniors", + "@aboutStoryLocalTitle": {"description": "Heading for local focus story"}, + "aboutStoryLocalBody": "Spurred by their experiences abroad and a deep-rooted sense of responsibility, Sydney and Sherman redirected their efforts towards supporting seniors in North Texas who lacked family and resources. Acknowledging the increasing challenges faced by poor and disadvantaged seniors in accessing essential services, Sydney, with the unwavering support of Sherman, founded Any Step Community Services.", + "@aboutStoryLocalBody": {"description": "Body paragraph for local focus story"}, "eventNotificationsTitle": "Event notifications", "@eventNotificationsTitle": {"description": "Settings label for event notification toggle"}, "eventNotificationsDescription": "Notify me when a new event is added.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 0a4b918..c850c8e 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -341,6 +341,38 @@ "@onboardingImpactDesc": {"description": "Descripción de la página de impacto"}, "notificationSettingsTitle": "Configuración de notificaciones", "@notificationSettingsTitle": {"description": "Etiqueta de ajustes para la página y el interruptor de notificaciones"}, + "aboutTitle": "Acerca de", + "@aboutTitle": {"description": "Título de la página Acerca de y enlace en ajustes"}, + "aboutDescription": "AnyStep ayuda a las personas voluntarias a encontrar y registrar oportunidades de servicio comunitario.", + "@aboutDescription": {"description": "Descripción breve en la página Acerca de"}, + "aboutInstagram": "Instagram", + "@aboutInstagram": {"description": "Etiqueta del enlace a Instagram"}, + "aboutLinkedIn": "LinkedIn", + "@aboutLinkedIn": {"description": "Etiqueta del enlace a LinkedIn"}, + "aboutFacebook": "Facebook", + "@aboutFacebook": {"description": "Etiqueta del enlace a Facebook"}, + "aboutX": "X", + "@aboutFX": {"description": "Etiqueta del enlace a X"}, + "aboutStoryTitle": "Nuestra historia", + "@aboutStoryTitle": {"description": "Titulo de la seccion expandible de la historia"}, + "aboutStoryIntro": "At the heart of Any Step Community Services lies a story of personal conviction and a shared commitment to making a difference. Our founder Sydney and her husband Sherman Diggs draw their passion for alleviating food insecurity from profound personal experiences.", + "@aboutStoryIntro": {"description": "Parrafo introductorio de la historia"}, + "aboutStorySydneyTitle": "Sydney's Story: A Witness to Resilience", + "@aboutStorySydneyTitle": {"description": "Encabezado de la historia de Sydney"}, + "aboutStorySydneyBody": "Raised in a single-parent home after her parents' divorce, Sydney observed her mother navigate life with unwavering determination. Working tirelessly as a gerontological nurse for over thirty years, Sydney's mother faced the challenge of surviving on meager Social Security benefits. Witnessing her mother's daily struggle ignited Sydney's determination to make a difference. Without the support of Sydney and her husband Sherman, her mother would have faced an even more challenging journey to secure nutritious food.", + "@aboutStorySydneyBody": {"description": "Cuerpo de la historia de Sydney"}, + "aboutStoryShermanTitle": "Sherman's Journey: From Poverty to Advocacy", + "@aboutStoryShermanTitle": {"description": "Encabezado de la historia de Sherman"}, + "aboutStoryShermanBody": "As the youngest of thirteen, Sherman's upbringing was marked by the hardships of a single-parent household after his parents separated. Personally acquainted with the realities of poverty and hunger, Sherman's life experiences became the driving force behind his unwavering passion for ensuring food equity for all.", + "@aboutStoryShermanBody": {"description": "Cuerpo de la historia de Sherman"}, + "aboutStoryCostaRicaTitle": "A Turning Point: Impactful Journey to Costa Rica", + "@aboutStoryCostaRicaTitle": {"description": "Encabezado del viaje a Costa Rica"}, + "aboutStoryCostaRicaBody": "In 2016, the Diggs embarked on a transformative journey with their dear friends Michael and Seidy Trent to Costa Rica. Inspired by the experience, Sydney organized a visit to an elder facility in Heredia, Costa Rica. The couple generously donated undergarments, hygienic products, and sandals, recognizing the immense need within the community. The impact of this trip prompted Sydney to return to Costa Rica with even more donations, this time filling the local food pantry.", + "@aboutStoryCostaRicaBody": {"description": "Cuerpo del viaje a Costa Rica"}, + "aboutStoryLocalTitle": "A Local Focus: Bridging the Gap for North Texas Seniors", + "@aboutStoryLocalTitle": {"description": "Encabezado del enfoque local"}, + "aboutStoryLocalBody": "Spurred by their experiences abroad and a deep-rooted sense of responsibility, Sydney and Sherman redirected their efforts towards supporting seniors in North Texas who lacked family and resources. Acknowledging the increasing challenges faced by poor and disadvantaged seniors in accessing essential services, Sydney, with the unwavering support of Sherman, founded Any Step Community Services.", + "@aboutStoryLocalBody": {"description": "Cuerpo del enfoque local"}, "eventNotificationsTitle": "Notificaciones de eventos", "@eventNotificationsTitle": {"description": "Etiqueta de ajustes para el interruptor de notificaciones de eventos"}, "eventNotificationsDescription": "Avísame cuando se agregue un nuevo evento.", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 380bf76..e226bb1 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -974,6 +974,102 @@ abstract class AppLocalizations { /// **'Notification Settings'** String get notificationSettingsTitle; + /// Title for About page and settings link + /// + /// In en, this message translates to: + /// **'About'** + String get aboutTitle; + + /// Short description shown on About page + /// + /// In en, this message translates to: + /// **'AnyStep helps volunteers find and track community service opportunities.'** + String get aboutDescription; + + /// Label for Instagram link + /// + /// In en, this message translates to: + /// **'Instagram'** + String get aboutInstagram; + + /// Label for LinkedIn link + /// + /// In en, this message translates to: + /// **'LinkedIn'** + String get aboutLinkedIn; + + /// Label for Facebook link + /// + /// In en, this message translates to: + /// **'Facebook'** + String get aboutFacebook; + + /// Label for X (formerly Twitter) link + /// + /// In en, this message translates to: + /// **'X'** + String get aboutX; + + /// Title for the expandable About story section + /// + /// In en, this message translates to: + /// **'Our Story'** + String get aboutStoryTitle; + + /// Intro paragraph for About story + /// + /// In en, this message translates to: + /// **'At the heart of Any Step Community Services lies a story of personal conviction and a shared commitment to making a difference. Our founder Sydney and her husband Sherman Diggs draw their passion for alleviating food insecurity from profound personal experiences.'** + String get aboutStoryIntro; + + /// Heading for Sydney story + /// + /// In en, this message translates to: + /// **'Sydney\'s Story: A Witness to Resilience'** + String get aboutStorySydneyTitle; + + /// Body paragraph for Sydney story + /// + /// In en, this message translates to: + /// **'Raised in a single-parent home after her parents\' divorce, Sydney observed her mother navigate life with unwavering determination. Working tirelessly as a gerontological nurse for over thirty years, Sydney\'s mother faced the challenge of surviving on meager Social Security benefits. Witnessing her mother\'s daily struggle ignited Sydney\'s determination to make a difference. Without the support of Sydney and her husband Sherman, her mother would have faced an even more challenging journey to secure nutritious food.'** + String get aboutStorySydneyBody; + + /// Heading for Sherman story + /// + /// In en, this message translates to: + /// **'Sherman\'s Journey: From Poverty to Advocacy'** + String get aboutStoryShermanTitle; + + /// Body paragraph for Sherman story + /// + /// In en, this message translates to: + /// **'As the youngest of thirteen, Sherman\'s upbringing was marked by the hardships of a single-parent household after his parents separated. Personally acquainted with the realities of poverty and hunger, Sherman\'s life experiences became the driving force behind his unwavering passion for ensuring food equity for all.'** + String get aboutStoryShermanBody; + + /// Heading for Costa Rica story + /// + /// In en, this message translates to: + /// **'A Turning Point: Impactful Journey to Costa Rica'** + String get aboutStoryCostaRicaTitle; + + /// Body paragraph for Costa Rica story + /// + /// In en, this message translates to: + /// **'In 2016, the Diggs embarked on a transformative journey with their dear friends Michael and Seidy Trent to Costa Rica. Inspired by the experience, Sydney organized a visit to an elder facility in Heredia, Costa Rica. The couple generously donated undergarments, hygienic products, and sandals, recognizing the immense need within the community. The impact of this trip prompted Sydney to return to Costa Rica with even more donations, this time filling the local food pantry.'** + String get aboutStoryCostaRicaBody; + + /// Heading for local focus story + /// + /// In en, this message translates to: + /// **'A Local Focus: Bridging the Gap for North Texas Seniors'** + String get aboutStoryLocalTitle; + + /// Body paragraph for local focus story + /// + /// In en, this message translates to: + /// **'Spurred by their experiences abroad and a deep-rooted sense of responsibility, Sydney and Sherman redirected their efforts towards supporting seniors in North Texas who lacked family and resources. Acknowledging the increasing challenges faced by poor and disadvantaged seniors in accessing essential services, Sydney, with the unwavering support of Sherman, founded Any Step Community Services.'** + String get aboutStoryLocalBody; + /// Settings label for event notification toggle /// /// 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 8637716..e141e00 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -480,6 +480,64 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notificationSettingsTitle => 'Notification Settings'; + @override + String get aboutTitle => 'About'; + + @override + String get aboutDescription => + 'AnyStep helps volunteers find and track community service opportunities.'; + + @override + String get aboutInstagram => 'Instagram'; + + @override + String get aboutLinkedIn => 'LinkedIn'; + + @override + String get aboutFacebook => 'Facebook'; + + @override + String get aboutX => 'X'; + + @override + String get aboutStoryTitle => 'Our Story'; + + @override + String get aboutStoryIntro => + 'At the heart of Any Step Community Services lies a story of personal conviction and a shared commitment to making a difference. Our founder Sydney and her husband Sherman Diggs draw their passion for alleviating food insecurity from profound personal experiences.'; + + @override + String get aboutStorySydneyTitle => + 'Sydney\'s Story: A Witness to Resilience'; + + @override + String get aboutStorySydneyBody => + 'Raised in a single-parent home after her parents\' divorce, Sydney observed her mother navigate life with unwavering determination. Working tirelessly as a gerontological nurse for over thirty years, Sydney\'s mother faced the challenge of surviving on meager Social Security benefits. Witnessing her mother\'s daily struggle ignited Sydney\'s determination to make a difference. Without the support of Sydney and her husband Sherman, her mother would have faced an even more challenging journey to secure nutritious food.'; + + @override + String get aboutStoryShermanTitle => + 'Sherman\'s Journey: From Poverty to Advocacy'; + + @override + String get aboutStoryShermanBody => + 'As the youngest of thirteen, Sherman\'s upbringing was marked by the hardships of a single-parent household after his parents separated. Personally acquainted with the realities of poverty and hunger, Sherman\'s life experiences became the driving force behind his unwavering passion for ensuring food equity for all.'; + + @override + String get aboutStoryCostaRicaTitle => + 'A Turning Point: Impactful Journey to Costa Rica'; + + @override + String get aboutStoryCostaRicaBody => + 'In 2016, the Diggs embarked on a transformative journey with their dear friends Michael and Seidy Trent to Costa Rica. Inspired by the experience, Sydney organized a visit to an elder facility in Heredia, Costa Rica. The couple generously donated undergarments, hygienic products, and sandals, recognizing the immense need within the community. The impact of this trip prompted Sydney to return to Costa Rica with even more donations, this time filling the local food pantry.'; + + @override + String get aboutStoryLocalTitle => + 'A Local Focus: Bridging the Gap for North Texas Seniors'; + + @override + String get aboutStoryLocalBody => + 'Spurred by their experiences abroad and a deep-rooted sense of responsibility, Sydney and Sherman redirected their efforts towards supporting seniors in North Texas who lacked family and resources. Acknowledging the increasing challenges faced by poor and disadvantaged seniors in accessing essential services, Sydney, with the unwavering support of Sherman, founded Any Step Community Services.'; + @override String get eventNotificationsTitle => 'Event notifications'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index f8ce0f0..550cf68 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -484,6 +484,64 @@ class AppLocalizationsEs extends AppLocalizations { @override String get notificationSettingsTitle => 'Configuración de notificaciones'; + @override + String get aboutTitle => 'Acerca de'; + + @override + String get aboutDescription => + 'AnyStep ayuda a las personas voluntarias a encontrar y registrar oportunidades de servicio comunitario.'; + + @override + String get aboutInstagram => 'Instagram'; + + @override + String get aboutLinkedIn => 'LinkedIn'; + + @override + String get aboutFacebook => 'Facebook'; + + @override + String get aboutX => 'X'; + + @override + String get aboutStoryTitle => 'Nuestra historia'; + + @override + String get aboutStoryIntro => + 'At the heart of Any Step Community Services lies a story of personal conviction and a shared commitment to making a difference. Our founder Sydney and her husband Sherman Diggs draw their passion for alleviating food insecurity from profound personal experiences.'; + + @override + String get aboutStorySydneyTitle => + 'Sydney\'s Story: A Witness to Resilience'; + + @override + String get aboutStorySydneyBody => + 'Raised in a single-parent home after her parents\' divorce, Sydney observed her mother navigate life with unwavering determination. Working tirelessly as a gerontological nurse for over thirty years, Sydney\'s mother faced the challenge of surviving on meager Social Security benefits. Witnessing her mother\'s daily struggle ignited Sydney\'s determination to make a difference. Without the support of Sydney and her husband Sherman, her mother would have faced an even more challenging journey to secure nutritious food.'; + + @override + String get aboutStoryShermanTitle => + 'Sherman\'s Journey: From Poverty to Advocacy'; + + @override + String get aboutStoryShermanBody => + 'As the youngest of thirteen, Sherman\'s upbringing was marked by the hardships of a single-parent household after his parents separated. Personally acquainted with the realities of poverty and hunger, Sherman\'s life experiences became the driving force behind his unwavering passion for ensuring food equity for all.'; + + @override + String get aboutStoryCostaRicaTitle => + 'A Turning Point: Impactful Journey to Costa Rica'; + + @override + String get aboutStoryCostaRicaBody => + 'In 2016, the Diggs embarked on a transformative journey with their dear friends Michael and Seidy Trent to Costa Rica. Inspired by the experience, Sydney organized a visit to an elder facility in Heredia, Costa Rica. The couple generously donated undergarments, hygienic products, and sandals, recognizing the immense need within the community. The impact of this trip prompted Sydney to return to Costa Rica with even more donations, this time filling the local food pantry.'; + + @override + String get aboutStoryLocalTitle => + 'A Local Focus: Bridging the Gap for North Texas Seniors'; + + @override + String get aboutStoryLocalBody => + 'Spurred by their experiences abroad and a deep-rooted sense of responsibility, Sydney and Sherman redirected their efforts towards supporting seniors in North Texas who lacked family and resources. Acknowledging the increasing challenges faced by poor and disadvantaged seniors in accessing essential services, Sydney, with the unwavering support of Sherman, founded Any Step Community Services.'; + @override String get eventNotificationsTitle => 'Notificaciones de eventos'; diff --git a/pubspec.lock b/pubspec.lock index 3b8b026..342ddea 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -525,6 +525,14 @@ packages: description: flutter source: sdk version: "0.0.0" + font_awesome_flutter: + dependency: "direct main" + description: + name: font_awesome_flutter + sha256: b9011df3a1fa02993630b8fb83526368cf2206a711259830325bab2f1d2a4eb0 + url: "https://pub.dev" + source: hosted + version: "10.12.0" form_builder_validators: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index be7f565..04c6399 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,6 +25,7 @@ dependencies: flutter_riverpod: ^3.0.0 fl_chart: ^0.68.0 table_calendar: ^3.1.2 + font_awesome_flutter: ^10.7.0 form_builder_validators: ^11.1.2 freezed_annotation: ^3.0.0 go_router: ^15.1.2