diff --git a/assets/images/profile.svg b/assets/images/profile.svg new file mode 100644 index 0000000..55239bd --- /dev/null +++ b/assets/images/profile.svg @@ -0,0 +1,11 @@ + + + Profile + + + + + + + + \ No newline at end of file diff --git a/lib/src/core/design_system/components/molecules/profile_avatar.dart b/lib/src/core/design_system/components/molecules/profile_avatar.dart index 1983b45..6e68df9 100644 --- a/lib/src/core/design_system/components/molecules/profile_avatar.dart +++ b/lib/src/core/design_system/components/molecules/profile_avatar.dart @@ -3,6 +3,7 @@ import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter/material.dart'; import 'package:spark/src/core/design_system/tokens/gradients.dart'; import 'package:spark/src/core/ui/foundation/colors.dart'; +import 'package:spark/src/core/ui/widgets/default_profile_avatar.dart'; class ProfileAvatar extends StatelessWidget { const ProfileAvatar({ @@ -142,13 +143,7 @@ class ProfileAvatar extends StatelessWidget { color: isDarkMode ? AppColors.darkPurple : AppColors.lightLavender, shape: BoxShape.circle, ), - child: Center( - child: Icon( - FluentIcons.person_24_regular, - size: avatarSize * 0.44, - color: isDarkMode ? AppColors.textLight : AppColors.textSecondary, - ), - ), + child: DefaultProfileAvatar(size: avatarSize), ); } } diff --git a/lib/src/core/design_system/components/molecules/story_circle.dart b/lib/src/core/design_system/components/molecules/story_circle.dart index 090463c..5276fef 100644 --- a/lib/src/core/design_system/components/molecules/story_circle.dart +++ b/lib/src/core/design_system/components/molecules/story_circle.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:spark/src/core/design_system/tokens/gradients.dart'; import 'package:spark/src/core/design_system/tokens/typography.dart'; import 'package:spark/src/core/ui/foundation/colors.dart'; +import 'package:spark/src/core/ui/widgets/default_profile_avatar.dart'; enum StoryType { story, live, cf, create } @@ -118,27 +119,12 @@ class StoryCircle extends StatelessWidget { width: _imageSize, height: _imageSize, fit: BoxFit.cover, - errorWidget: (context, url, error) => Container( - width: _imageSize, - height: _imageSize, - color: const Color(0xFFD9D9D9), - child: const Icon( - Icons.person, - size: _imageSize * 0.5, - color: Colors.grey, - ), - ), + errorWidget: (context, url, error) => + const DefaultProfileAvatar( + size: _imageSize, + ), ) - : Container( - width: _imageSize, - height: _imageSize, - color: const Color(0xFFD9D9D9), - child: const Icon( - Icons.person, - size: _imageSize * 0.5, - color: Colors.grey, - ), - ), + : const DefaultProfileAvatar(size: _imageSize), ), ), ), diff --git a/lib/src/core/design_system/components/organisms/bottom_nav_bar.dart b/lib/src/core/design_system/components/organisms/bottom_nav_bar.dart index af0d758..019e38b 100644 --- a/lib/src/core/design_system/components/organisms/bottom_nav_bar.dart +++ b/lib/src/core/design_system/components/organisms/bottom_nav_bar.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spark/src/core/design_system/components/atoms/icons.dart'; import 'package:spark/src/core/design_system/tokens/constants.dart'; import 'package:spark/src/core/ui/foundation/colors.dart'; +import 'package:spark/src/core/ui/widgets/default_profile_avatar.dart'; import 'package:spark/src/features/notifications/providers/unread_count_provider.dart'; class SparkBottomNavBar extends ConsumerWidget { @@ -247,13 +248,7 @@ class _ProfileAvatar extends StatelessWidget { ? (isDark ? const Color(0xFF424242) : const Color(0xFFE0E0E0)) : null, ), - child: image is AssetImage - ? Icon( - Icons.person, - size: 18, - color: isDark ? const Color(0xFF9E9E9E) : const Color(0xFF757575), - ) - : null, + child: image is AssetImage ? const DefaultProfileAvatar(size: 34) : null, ); return GestureDetector(onTap: onTap, child: avatar); } diff --git a/lib/src/core/l10n/app_localizations.dart b/lib/src/core/l10n/app_localizations.dart index fd96d3b..39b9a0e 100644 --- a/lib/src/core/l10n/app_localizations.dart +++ b/lib/src/core/l10n/app_localizations.dart @@ -262,6 +262,138 @@ abstract class AppLocalizations { /// **'Complete your profile'** String get pageTitleCompleteProfile; + /// Current onboarding step count + /// + /// In en, this message translates to: + /// **'Step {current} of {total}'** + String onboardingStepCount(int current, int total); + + /// Onboarding intro step title + /// + /// In en, this message translates to: + /// **'Set up your profile'** + String get onboardingIntroStepTitle; + + /// Main heading on onboarding intro step + /// + /// In en, this message translates to: + /// **'Let’s get your profile ready.'** + String get onboardingIntroTitle; + + /// Intro text explaining onboarding profile setup + /// + /// In en, this message translates to: + /// **'Next you’ll add a profile photo, display name, and bio. It only takes a minute, and you can edit everything later.'** + String get onboardingIntroDescription; + + /// Onboarding avatar step title + /// + /// In en, this message translates to: + /// **'Add a profile photo'** + String get onboardingAvatarStepTitle; + + /// Onboarding display name step title + /// + /// In en, this message translates to: + /// **'Add your display name'** + String get onboardingNameStepTitle; + + /// Onboarding bio step title + /// + /// In en, this message translates to: + /// **'Add a bio'** + String get onboardingBioStepTitle; + + /// Small label on the onboarding avatar step + /// + /// In en, this message translates to: + /// **'Profile photo'** + String get onboardingAvatarEyebrow; + + /// Main heading on the onboarding avatar step + /// + /// In en, this message translates to: + /// **'Make Spark feel like you.'** + String get onboardingAvatarTitle; + + /// Subtitle on the onboarding avatar step + /// + /// In en, this message translates to: + /// **'Start with the avatar people will recognize in feeds, comments, and profiles.'** + String get onboardingAvatarSubtitle; + + /// Message shown when Bluesky profile details autofill onboarding + /// + /// In en, this message translates to: + /// **'Imported from your Bluesky profile. You can change it.'** + String get onboardingBskyAutofill; + + /// Button label to restore the Bluesky avatar during onboarding + /// + /// In en, this message translates to: + /// **'Use Bluesky avatar'** + String get onboardingUseBskyAvatar; + + /// Small label on the onboarding display name step + /// + /// In en, this message translates to: + /// **'Display name'** + String get onboardingNameEyebrow; + + /// Main heading on the onboarding display name step + /// + /// In en, this message translates to: + /// **'What should people call you?'** + String get onboardingNameTitle; + + /// Subtitle on the onboarding display name step + /// + /// In en, this message translates to: + /// **'Use a real name, a handle, or whatever makes your posts feel unmistakably yours.'** + String get onboardingNameSubtitle; + + /// Hint shown above the display name field during onboarding + /// + /// In en, this message translates to: + /// **'This is the name people see first, so keep it clear and easy to recognize.'** + String get onboardingNamePreviewHint; + + /// Small label on the onboarding bio step + /// + /// In en, this message translates to: + /// **'Bio'** + String get onboardingBioEyebrow; + + /// Main heading on the onboarding bio step + /// + /// In en, this message translates to: + /// **'Write the first line of your profile.'** + String get onboardingBioTitle; + + /// Subtitle on the onboarding bio step + /// + /// In en, this message translates to: + /// **'A sentence is enough. Tell people what you post, what you like, or why they should follow along.'** + String get onboardingBioSubtitle; + + /// Tip shown under the onboarding bio field + /// + /// In en, this message translates to: + /// **'You can always polish this later from your profile settings.'** + String get onboardingBioTip; + + /// Validation error when onboarding display name is too long + /// + /// In en, this message translates to: + /// **'Display Name cannot exceed 64 characters'** + String get onboardingDisplayNameTooLong; + + /// Validation error when onboarding bio is too long + /// + /// In en, this message translates to: + /// **'Bio cannot exceed 256 characters'** + String get onboardingBioTooLong; + /// Labeler settings page title /// /// In en, this message translates to: diff --git a/lib/src/core/l10n/app_localizations_en.dart b/lib/src/core/l10n/app_localizations_en.dart index 1979086..8955c40 100644 --- a/lib/src/core/l10n/app_localizations_en.dart +++ b/lib/src/core/l10n/app_localizations_en.dart @@ -92,6 +92,82 @@ class AppLocalizationsEn extends AppLocalizations { @override String get pageTitleCompleteProfile => 'Complete your profile'; + @override + String onboardingStepCount(int current, int total) { + return 'Step $current of $total'; + } + + @override + String get onboardingIntroStepTitle => 'Set up your profile'; + + @override + String get onboardingIntroTitle => 'Let’s get your profile ready.'; + + @override + String get onboardingIntroDescription => + 'Next you’ll add a profile photo, display name, and bio. It only takes a minute, and you can edit everything later.'; + + @override + String get onboardingAvatarStepTitle => 'Add a profile photo'; + + @override + String get onboardingNameStepTitle => 'Add your display name'; + + @override + String get onboardingBioStepTitle => 'Add a bio'; + + @override + String get onboardingAvatarEyebrow => 'Profile photo'; + + @override + String get onboardingAvatarTitle => 'Make Spark feel like you.'; + + @override + String get onboardingAvatarSubtitle => + 'Start with the avatar people will recognize in feeds, comments, and profiles.'; + + @override + String get onboardingBskyAutofill => + 'Imported from your Bluesky profile. You can change it.'; + + @override + String get onboardingUseBskyAvatar => 'Use Bluesky avatar'; + + @override + String get onboardingNameEyebrow => 'Display name'; + + @override + String get onboardingNameTitle => 'What should people call you?'; + + @override + String get onboardingNameSubtitle => + 'Use a real name, a handle, or whatever makes your posts feel unmistakably yours.'; + + @override + String get onboardingNamePreviewHint => + 'This is the name people see first, so keep it clear and easy to recognize.'; + + @override + String get onboardingBioEyebrow => 'Bio'; + + @override + String get onboardingBioTitle => 'Write the first line of your profile.'; + + @override + String get onboardingBioSubtitle => + 'A sentence is enough. Tell people what you post, what you like, or why they should follow along.'; + + @override + String get onboardingBioTip => + 'You can always polish this later from your profile settings.'; + + @override + String get onboardingDisplayNameTooLong => + 'Display Name cannot exceed 64 characters'; + + @override + String get onboardingBioTooLong => 'Bio cannot exceed 256 characters'; + @override String get pageTitleLabelerSettings => 'Labeler Settings'; diff --git a/lib/src/core/l10n/intl_en.arb b/lib/src/core/l10n/intl_en.arb index 165994f..337eeb6 100644 --- a/lib/src/core/l10n/intl_en.arb +++ b/lib/src/core/l10n/intl_en.arb @@ -142,6 +142,124 @@ "description": "Complete profile page title" }, + "onboardingStepCount": "Step {current} of {total}", + "@onboardingStepCount": { + "description": "Current onboarding step count", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + + "onboardingIntroStepTitle": "Set up your profile", + "@onboardingIntroStepTitle": { + "description": "Onboarding intro step title" + }, + + "onboardingIntroTitle": "Let’s get your profile ready.", + "@onboardingIntroTitle": { + "description": "Main heading on onboarding intro step" + }, + + "onboardingIntroDescription": "Next you’ll add a profile photo, display name, and bio. It only takes a minute, and you can edit everything later.", + "@onboardingIntroDescription": { + "description": "Intro text explaining onboarding profile setup" + }, + + "onboardingAvatarStepTitle": "Add a profile photo", + "@onboardingAvatarStepTitle": { + "description": "Onboarding avatar step title" + }, + + "onboardingNameStepTitle": "Add your display name", + "@onboardingNameStepTitle": { + "description": "Onboarding display name step title" + }, + + "onboardingBioStepTitle": "Add a bio", + "@onboardingBioStepTitle": { + "description": "Onboarding bio step title" + }, + + "onboardingAvatarEyebrow": "Profile photo", + "@onboardingAvatarEyebrow": { + "description": "Small label on the onboarding avatar step" + }, + + "onboardingAvatarTitle": "Make Spark feel like you.", + "@onboardingAvatarTitle": { + "description": "Main heading on the onboarding avatar step" + }, + + "onboardingAvatarSubtitle": "Start with the avatar people will recognize in feeds, comments, and profiles.", + "@onboardingAvatarSubtitle": { + "description": "Subtitle on the onboarding avatar step" + }, + + "onboardingBskyAutofill": "Imported from your Bluesky profile. You can change it.", + "@onboardingBskyAutofill": { + "description": "Message shown when Bluesky profile details autofill onboarding" + }, + + "onboardingUseBskyAvatar": "Use Bluesky avatar", + "@onboardingUseBskyAvatar": { + "description": "Button label to restore the Bluesky avatar during onboarding" + }, + + "onboardingNameEyebrow": "Display name", + "@onboardingNameEyebrow": { + "description": "Small label on the onboarding display name step" + }, + + "onboardingNameTitle": "What should people call you?", + "@onboardingNameTitle": { + "description": "Main heading on the onboarding display name step" + }, + + "onboardingNameSubtitle": "Use a real name, a handle, or whatever makes your posts feel unmistakably yours.", + "@onboardingNameSubtitle": { + "description": "Subtitle on the onboarding display name step" + }, + + "onboardingNamePreviewHint": "This is the name people see first, so keep it clear and easy to recognize.", + "@onboardingNamePreviewHint": { + "description": "Hint shown above the display name field during onboarding" + }, + + "onboardingBioEyebrow": "Bio", + "@onboardingBioEyebrow": { + "description": "Small label on the onboarding bio step" + }, + + "onboardingBioTitle": "Write the first line of your profile.", + "@onboardingBioTitle": { + "description": "Main heading on the onboarding bio step" + }, + + "onboardingBioSubtitle": "A sentence is enough. Tell people what you post, what you like, or why they should follow along.", + "@onboardingBioSubtitle": { + "description": "Subtitle on the onboarding bio step" + }, + + "onboardingBioTip": "You can always polish this later from your profile settings.", + "@onboardingBioTip": { + "description": "Tip shown under the onboarding bio field" + }, + + "onboardingDisplayNameTooLong": "Display Name cannot exceed 64 characters", + "@onboardingDisplayNameTooLong": { + "description": "Validation error when onboarding display name is too long" + }, + + "onboardingBioTooLong": "Bio cannot exceed 256 characters", + "@onboardingBioTooLong": { + "description": "Validation error when onboarding bio is too long" + }, + "pageTitleLabelerSettings": "Labeler Settings", "@pageTitleLabelerSettings": { "description": "Labeler settings page title" diff --git a/lib/src/core/pro_image_editor/ui/widgets/story_mention_picker_sheet.dart b/lib/src/core/pro_image_editor/ui/widgets/story_mention_picker_sheet.dart index 28acb0e..79d2009 100644 --- a/lib/src/core/pro_image_editor/ui/widgets/story_mention_picker_sheet.dart +++ b/lib/src/core/pro_image_editor/ui/widgets/story_mention_picker_sheet.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; -import 'package:sprk_poptart/so/sprk/actor/defs.dart'; +import 'package:spark/src/core/ui/widgets/user_avatar.dart'; import 'package:spark/src/features/search/providers/actor_typeahead_provider.dart'; import 'package:spark/src/features/search/providers/actor_typeahead_state.dart'; +import 'package:sprk_poptart/so/sprk/actor/defs.dart'; Future showStoryMentionPickerSheet(BuildContext context) { return showModalBottomSheet( @@ -165,14 +166,9 @@ class _ResultsList extends StatelessWidget { final actor = typeaheadState.results[index]; return ListTile( contentPadding: EdgeInsets.zero, - leading: CircleAvatar( - backgroundColor: const Color(0x1AFFFFFF), - backgroundImage: actor.avatar != null - ? NetworkImage(actor.avatar.toString()) - : null, - child: actor.avatar == null - ? const Icon(Icons.person_outline, color: Colors.white) - : null, + leading: UserAvatar( + imageUrl: actor.avatar?.toString() ?? '', + size: 40, ), title: Text( actor.displayName ?? actor.handle, diff --git a/lib/src/core/ui/widgets/custom_text_field.dart b/lib/src/core/ui/widgets/custom_text_field.dart index 0b011ac..eb9c389 100644 --- a/lib/src/core/ui/widgets/custom_text_field.dart +++ b/lib/src/core/ui/widgets/custom_text_field.dart @@ -11,6 +11,10 @@ class CustomTextField extends StatelessWidget { this.maxLines = 1, this.onUndo, this.validator, + this.contentPadding, + this.textStyle, + this.hintStyle, + this.borderRadius, }); final TextEditingController controller; final String hintText; @@ -18,6 +22,10 @@ class CustomTextField extends StatelessWidget { final int maxLines; final VoidCallback? onUndo; final String? Function(String?)? validator; + final EdgeInsetsGeometry? contentPadding; + final TextStyle? textStyle; + final TextStyle? hintStyle; + final double? borderRadius; @override Widget build(BuildContext context) { @@ -28,28 +36,29 @@ class CustomTextField extends StatelessWidget { controller: controller, maxLines: maxLines, validator: validator, + style: textStyle, decoration: InputDecoration( hintText: hintText, + hintStyle: hintStyle, filled: true, fillColor: fillColor ?? colorScheme.surface, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), + contentPadding: + contentPadding ?? + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(borderRadius ?? 8), borderSide: BorderSide(color: colorScheme.outline), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(borderRadius ?? 8), borderSide: BorderSide(color: colorScheme.primary), ), errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(borderRadius ?? 8), borderSide: BorderSide(color: colorScheme.error), ), focusedErrorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(borderRadius ?? 8), borderSide: BorderSide(color: colorScheme.error), ), suffixIcon: onUndo != null diff --git a/lib/src/core/ui/widgets/default_profile_avatar.dart b/lib/src/core/ui/widgets/default_profile_avatar.dart new file mode 100644 index 0000000..d144fc1 --- /dev/null +++ b/lib/src/core/ui/widgets/default_profile_avatar.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class DefaultProfileAvatar extends StatelessWidget { + const DefaultProfileAvatar({required this.size, super.key}); + + static const assetName = 'images/profile.svg'; + static const assetPackage = 'assets'; + + final double size; + + @override + Widget build(BuildContext context) { + return ClipOval( + child: SvgPicture.asset( + assetName, + package: assetPackage, + width: size, + height: size, + fit: BoxFit.cover, + ), + ); + } +} diff --git a/lib/src/core/ui/widgets/user_avatar.dart b/lib/src/core/ui/widgets/user_avatar.dart index d6dfdf8..4f6a2cd 100644 --- a/lib/src/core/ui/widgets/user_avatar.dart +++ b/lib/src/core/ui/widgets/user_avatar.dart @@ -1,6 +1,6 @@ import 'package:cached_network_image/cached_network_image.dart'; -import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter/material.dart'; +import 'package:spark/src/core/ui/widgets/default_profile_avatar.dart'; /// A customizable user avatar with fallback options when no image is available class UserAvatar extends StatelessWidget { @@ -29,8 +29,6 @@ class UserAvatar extends StatelessWidget { final effectiveBorderColor = borderColor ?? colorScheme.outline; final effectiveBackgroundColor = backgroundColor ?? colorScheme.primary; - final effectiveFallbackTextColor = - fallbackTextColor ?? colorScheme.onPrimary; // If no image URL is provided, show fallback avatar if (imageUrl.isEmpty) { @@ -44,21 +42,7 @@ class UserAvatar extends StatelessWidget { ? Border.all(color: effectiveBorderColor, width: borderWidth) : null, ), - child: Center( - child: username.isNotEmpty - ? Text( - username[0].toUpperCase(), - style: TextStyle( - color: effectiveFallbackTextColor, - fontWeight: FontWeight.bold, - ), - ) - : Icon( - FluentIcons.person_24_regular, - size: size * 0.5, - color: effectiveFallbackTextColor, - ), - ), + child: DefaultProfileAvatar(size: size), ); } @@ -79,39 +63,11 @@ class UserAvatar extends StatelessWidget { fit: BoxFit.cover, placeholder: (context, url) => ColoredBox( color: effectiveBackgroundColor, - child: Center( - child: username.isNotEmpty - ? Text( - username[0].toUpperCase(), - style: TextStyle( - color: effectiveFallbackTextColor, - fontWeight: FontWeight.bold, - ), - ) - : Icon( - FluentIcons.person_24_regular, - size: size * 0.5, - color: effectiveFallbackTextColor, - ), - ), + child: DefaultProfileAvatar(size: size), ), errorWidget: (context, url, error) => ColoredBox( color: effectiveBackgroundColor, - child: Center( - child: username.isNotEmpty - ? Text( - username[0].toUpperCase(), - style: TextStyle( - color: effectiveFallbackTextColor, - fontWeight: FontWeight.bold, - ), - ) - : Icon( - FluentIcons.person_24_regular, - size: size * 0.5, - color: effectiveFallbackTextColor, - ), - ), + child: DefaultProfileAvatar(size: size), ), ), ); diff --git a/lib/src/features/auth/ui/onboarding/onboarding_sequence.dart b/lib/src/features/auth/ui/onboarding/onboarding_sequence.dart new file mode 100644 index 0000000..ad815ba --- /dev/null +++ b/lib/src/features/auth/ui/onboarding/onboarding_sequence.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; +import 'package:spark/src/features/auth/ui/onboarding/onboarding_step.dart'; + +/// A widget that displays a sequence of [OnboardingStep]s using an +/// [IndexedStack] so that form state is preserved across steps. +class OnboardingSequence extends StatefulWidget { + const OnboardingSequence({ + required this.steps, + this.initialIndex = 0, + this.onIndexChanged, + this.onComplete, + this.isCompleteLoading = false, + super.key, + }); + + final List steps; + final int initialIndex; + final ValueChanged? onIndexChanged; + final VoidCallback? onComplete; + final bool isCompleteLoading; + + @override + State createState() => _OnboardingSequenceState(); +} + +class _OnboardingSequenceState extends State { + late int _currentIndex; + + @override + void initState() { + super.initState(); + _currentIndex = widget.initialIndex; + } + + void _goToNext() { + final currentStep = widget.steps[_currentIndex]; + if (currentStep.canProceed != null && !currentStep.canProceed!()) { + return; + } + + if (_currentIndex < widget.steps.length - 1) { + setState(() { + _currentIndex++; + }); + widget.onIndexChanged?.call(_currentIndex); + } else { + widget.onComplete?.call(); + } + } + + void _goToPrevious() { + if (_currentIndex > 0) { + setState(() { + _currentIndex--; + }); + widget.onIndexChanged?.call(_currentIndex); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final l10n = AppLocalizations.of(context); + final currentStepNumber = _currentIndex + 1; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.onboardingStepCount( + currentStepNumber, + widget.steps.length, + ), + style: theme.textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Text( + widget.steps[_currentIndex].title, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 16), + TweenAnimationBuilder( + tween: Tween( + end: currentStepNumber / widget.steps.length, + ), + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + builder: (context, value, child) { + return LinearProgressIndicator( + value: value, + minHeight: 4, + borderRadius: BorderRadius.circular(999), + backgroundColor: colorScheme.surfaceContainerHighest, + ); + }, + ), + ], + ), + ), + Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: SizedBox.expand( + child: IndexedStack( + index: _currentIndex, + children: widget.steps + .map((step) => step.builder(context)) + .toList(), + ), + ), + ), + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 18), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Row( + children: [ + if (_currentIndex > 0) + Expanded( + child: OutlinedButton( + onPressed: _goToPrevious, + child: Text(l10n.buttonBack), + ), + ) + else + const Spacer(), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: ElevatedButton( + onPressed: widget.isCompleteLoading ? null : _goToNext, + child: + widget.isCompleteLoading && + _currentIndex == widget.steps.length - 1 + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Text( + _currentIndex < widget.steps.length - 1 + ? l10n.buttonContinue + : l10n.buttonConfirm, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/src/features/auth/ui/onboarding/onboarding_step.dart b/lib/src/features/auth/ui/onboarding/onboarding_step.dart new file mode 100644 index 0000000..7c82cbf --- /dev/null +++ b/lib/src/features/auth/ui/onboarding/onboarding_step.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +/// Represents a single step within an [OnboardingSequence]. +class OnboardingStep { + const OnboardingStep({ + required this.title, + required this.builder, + this.canProceed, + }); + + /// Title shown above the progress indicator for this step. + final String title; + + /// Builder that returns the content widget for this step. + final WidgetBuilder builder; + + /// Optional validation called before advancing to the next step. + /// Return `false` to block navigation. + final bool Function()? canProceed; +} diff --git a/lib/src/features/auth/ui/onboarding/steps/onboarding_avatar_step.dart b/lib/src/features/auth/ui/onboarding/steps/onboarding_avatar_step.dart new file mode 100644 index 0000000..c60d865 --- /dev/null +++ b/lib/src/features/auth/ui/onboarding/steps/onboarding_avatar_step.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; +import 'package:spark/src/core/ui/widgets/default_profile_avatar.dart'; + +class OnboardingAvatarStep extends StatefulWidget { + const OnboardingAvatarStep({ + this.hasImportedBskyProfile = false, + this.avatarImageProvider, + this.hasLocalAvatar = false, + this.hasInitialAvatar = false, + this.isAvatarActive = false, + this.onPickAvatar, + this.onRevertAvatar, + this.onClearAvatar, + super.key, + }); + + final bool hasImportedBskyProfile; + final ImageProvider? avatarImageProvider; + final bool hasLocalAvatar; + final bool hasInitialAvatar; + final bool isAvatarActive; + final VoidCallback? onPickAvatar; + final VoidCallback? onRevertAvatar; + final VoidCallback? onClearAvatar; + + @override + State createState() => _OnboardingAvatarStepState(); +} + +class _OnboardingAvatarStepState extends State { + bool _isAvatarPressed = false; + + void _setAvatarPressed(bool value) { + if (_isAvatarPressed == value) return; + setState(() => _isAvatarPressed = value); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final l10n = AppLocalizations.of(context); + + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.hasImportedBskyProfile) ...[ + Text( + l10n.onboardingBskyAutofill, + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + ], + GestureDetector( + onTap: widget.onPickAvatar, + onTapDown: (_) => _setAvatarPressed(true), + onTapCancel: () => _setAvatarPressed(false), + onTapUp: (_) => _setAvatarPressed(false), + child: AnimatedScale( + scale: _isAvatarPressed ? 0.97 : 1, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOutCubic, + child: CircleAvatar( + radius: 104, + backgroundImage: widget.avatarImageProvider, + backgroundColor: colorScheme.surfaceContainerHighest, + child: widget.avatarImageProvider == null + ? const DefaultProfileAvatar(size: 208) + : null, + ), + ), + ), + const SizedBox(height: 28), + Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 10, + children: [ + OutlinedButton.icon( + onPressed: widget.onPickAvatar, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 14, + ), + ), + icon: const Icon(Icons.add_a_photo_outlined), + label: Text(l10n.buttonAdd), + ), + if (widget.hasLocalAvatar) + TextButton.icon( + onPressed: widget.onRevertAvatar, + icon: const Icon(Icons.undo), + label: Text(l10n.tooltipRevert), + ), + if (widget.isAvatarActive) + TextButton.icon( + onPressed: widget.onClearAvatar, + icon: const Icon(Icons.close), + label: Text(l10n.buttonRemove), + ), + if (!widget.isAvatarActive && widget.hasInitialAvatar) + TextButton.icon( + onPressed: widget.onRevertAvatar, + icon: const Icon(Icons.undo), + label: Text(l10n.onboardingUseBskyAvatar), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/features/auth/ui/onboarding/steps/onboarding_bio_step.dart b/lib/src/features/auth/ui/onboarding/steps/onboarding_bio_step.dart new file mode 100644 index 0000000..48ef5bc --- /dev/null +++ b/lib/src/features/auth/ui/onboarding/steps/onboarding_bio_step.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; +import 'package:spark/src/core/ui/widgets/custom_text_field.dart'; + +class OnboardingBioStep extends StatefulWidget { + const OnboardingBioStep({ + this.initialDescription = '', + this.onUndoDescription, + super.key, + }); + + final String initialDescription; + final VoidCallback? onUndoDescription; + + @override + State createState() => OnboardingBioStepState(); +} + +class OnboardingBioStepState extends State { + final _formKey = GlobalKey(); + late final TextEditingController _descriptionController; + + String get description => _descriptionController.text; + + @override + void initState() { + super.initState(); + _descriptionController = TextEditingController(); + } + + @override + void dispose() { + _descriptionController.dispose(); + super.dispose(); + } + + bool validate() => _formKey.currentState?.validate() ?? false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final l10n = AppLocalizations.of(context); + final placeholder = widget.initialDescription.trim().isNotEmpty + ? widget.initialDescription + : 'This is my bio. There are many like it but this one is mine.'; + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 40, 20, 24), + child: Form( + key: _formKey, + child: CustomTextField( + controller: _descriptionController, + hintText: placeholder, + fillColor: + theme.inputDecorationTheme.fillColor ?? colorScheme.surface, + maxLines: 7, + contentPadding: const EdgeInsets.symmetric( + horizontal: 22, + vertical: 22, + ), + textStyle: theme.textTheme.titleLarge, + hintStyle: theme.textTheme.titleLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + borderRadius: 16, + onUndo: widget.onUndoDescription, + validator: (value) { + if (value != null && value.trim().length > 256) { + return l10n.onboardingBioTooLong; + } + return null; + }, + ), + ), + ); + } +} diff --git a/lib/src/features/auth/ui/onboarding/steps/onboarding_display_name_step.dart b/lib/src/features/auth/ui/onboarding/steps/onboarding_display_name_step.dart new file mode 100644 index 0000000..53089bf --- /dev/null +++ b/lib/src/features/auth/ui/onboarding/steps/onboarding_display_name_step.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; +import 'package:spark/src/core/ui/widgets/custom_text_field.dart'; + +class OnboardingDisplayNameStep extends StatefulWidget { + const OnboardingDisplayNameStep({ + this.initialDisplayName = '', + this.onUndoDisplayName, + super.key, + }); + + final String initialDisplayName; + final VoidCallback? onUndoDisplayName; + + @override + State createState() => + OnboardingDisplayNameStepState(); +} + +class OnboardingDisplayNameStepState extends State { + final _formKey = GlobalKey(); + late final TextEditingController _displayNameController; + + String get displayName => _displayNameController.text; + + @override + void initState() { + super.initState(); + _displayNameController = TextEditingController(); + } + + @override + void dispose() { + _displayNameController.dispose(); + super.dispose(); + } + + bool validate() => _formKey.currentState?.validate() ?? false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final l10n = AppLocalizations.of(context); + final placeholder = widget.initialDisplayName.trim().isNotEmpty + ? widget.initialDisplayName + : 'Jane Doe'; + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 40, 20, 24), + child: Form( + key: _formKey, + child: CustomTextField( + controller: _displayNameController, + hintText: placeholder, + fillColor: + theme.inputDecorationTheme.fillColor ?? colorScheme.surface, + contentPadding: const EdgeInsets.symmetric( + horizontal: 22, + vertical: 22, + ), + textStyle: theme.textTheme.headlineSmall, + hintStyle: theme.textTheme.headlineSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + borderRadius: 16, + onUndo: widget.onUndoDisplayName, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return l10n.inputErrorRequired; + } + if (value.trim().length > 64) { + return l10n.onboardingDisplayNameTooLong; + } + return null; + }, + ), + ), + ); + } +} diff --git a/lib/src/features/auth/ui/onboarding/steps/onboarding_welcome_step.dart b/lib/src/features/auth/ui/onboarding/steps/onboarding_welcome_step.dart new file mode 100644 index 0000000..9ee7795 --- /dev/null +++ b/lib/src/features/auth/ui/onboarding/steps/onboarding_welcome_step.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; + +/// Intro step for profile setup onboarding. +class OnboardingWelcomeStep extends StatelessWidget { + const OnboardingWelcomeStep({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context); + + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + l10n.onboardingIntroTitle, + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + l10n.onboardingIntroDescription, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.4, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/features/auth/ui/pages/login_page.dart b/lib/src/features/auth/ui/pages/login_page.dart index 19562d9..57e607f 100644 --- a/lib/src/features/auth/ui/pages/login_page.dart +++ b/lib/src/features/auth/ui/pages/login_page.dart @@ -10,13 +10,14 @@ import 'package:spark/src/core/design_system/components/atoms/buttons/app_overla import 'package:spark/src/core/design_system/components/atoms/buttons/long_button.dart'; import 'package:spark/src/core/design_system/tokens/typography.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; -import 'package:sprk_poptart/so/sprk/actor/defs.dart'; import 'package:spark/src/core/routing/app_router.dart'; +import 'package:spark/src/core/ui/widgets/user_avatar.dart'; import 'package:spark/src/features/auth/providers/auth_providers.dart'; import 'package:spark/src/features/auth/providers/onboarding_providers.dart'; import 'package:spark/src/features/search/providers/actor_typeahead_provider.dart'; import 'package:spark/src/features/search/providers/actor_typeahead_state.dart'; import 'package:spark/src/features/settings/providers/settings_provider.dart'; +import 'package:sprk_poptart/so/sprk/actor/defs.dart'; @RoutePage() class LoginPage extends ConsumerStatefulWidget { @@ -763,20 +764,7 @@ class _LoginHandleSuggestionTile extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ - CircleAvatar( - radius: 18, - backgroundColor: colorScheme.surfaceContainerHighest, - backgroundImage: actor.avatar != null - ? NetworkImage(actor.avatar.toString()) - : null, - child: actor.avatar == null - ? Icon( - FluentIcons.person_24_regular, - size: 18, - color: colorScheme.onSurfaceVariant, - ) - : null, - ), + UserAvatar(imageUrl: actor.avatar?.toString() ?? '', size: 36), const SizedBox(width: 12), Expanded( child: Column( diff --git a/lib/src/features/auth/ui/pages/onboarding_page.dart b/lib/src/features/auth/ui/pages/onboarding_page.dart index 8978322..d5cb352 100644 --- a/lib/src/features/auth/ui/pages/onboarding_page.dart +++ b/lib/src/features/auth/ui/pages/onboarding_page.dart @@ -1,12 +1,16 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spark/src/core/auth/data/models/onboarding_screen_state.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/routing/app_router.dart'; -import 'package:spark/src/core/ui/widgets/custom_text_field.dart'; import 'package:spark/src/features/auth/providers/onboarding_notifier.dart'; import 'package:spark/src/features/auth/providers/onboarding_providers.dart'; +import 'package:spark/src/features/auth/ui/onboarding/onboarding_sequence.dart'; +import 'package:spark/src/features/auth/ui/onboarding/onboarding_step.dart'; +import 'package:spark/src/features/auth/ui/onboarding/steps/onboarding_avatar_step.dart'; +import 'package:spark/src/features/auth/ui/onboarding/steps/onboarding_bio_step.dart'; +import 'package:spark/src/features/auth/ui/onboarding/steps/onboarding_display_name_step.dart'; +import 'package:spark/src/features/auth/ui/onboarding/steps/onboarding_welcome_step.dart'; import 'package:spark/src/features/settings/providers/settings_provider.dart'; @RoutePage() @@ -18,63 +22,25 @@ class OnboardingPage extends ConsumerStatefulWidget { } class _OnboardingPageState extends ConsumerState { - final _formKey = GlobalKey(); - late TextEditingController _displayNameController; - late TextEditingController _descriptionController; + final _displayNameStepKey = GlobalKey(); + final _bioStepKey = GlobalKey(); bool _isLoading = false; - @override - void initState() { - super.initState(); - _displayNameController = TextEditingController(); - _descriptionController = TextEditingController(); - _displayNameController.addListener(_updateDisplayName); - _descriptionController.addListener(_updateDescription); - } - - void _updateDisplayName() { - final currentProviderState = ref.read(onboardingProvider).value; - if (currentProviderState != null && - _displayNameController.text != currentProviderState.displayName) { - ref - .read(onboardingProvider.notifier) - .updateDisplayName(_displayNameController.text); - } - } - - void _updateDescription() { - final currentProviderState = ref.read(onboardingProvider).value; - if (currentProviderState != null && - _descriptionController.text != currentProviderState.description) { - ref - .read(onboardingProvider.notifier) - .updateDescription(_descriptionController.text); - } - } - - @override - void dispose() { - _displayNameController.removeListener(_updateDisplayName); - _descriptionController.removeListener(_updateDescription); - _displayNameController.dispose(); - _descriptionController.dispose(); - super.dispose(); - } - Future _handleCompleteOnboarding() async { - if (!_formKey.currentState!.validate()) return; + final displayNameState = _displayNameStepKey.currentState; + final bioState = _bioStepKey.currentState; + final isValid = + (displayNameState?.validate() ?? false) && + (bioState?.validate() ?? false); + if (!isValid) return; - setState(() { - _isLoading = true; - }); + setState(() => _isLoading = true); try { - // Create the profile final onboardingState = ref.read(onboardingStateProvider.notifier); + final currentState = ref.read(onboardingProvider).value; - // Determine avatar to use Object? avatarToUse; - final currentState = ref.read(onboardingProvider).value; if (currentState?.localAvatarBytes != null) { avatarToUse = currentState!.localAvatarBytes; } else if (currentState?.removeInitialAvatar != true && @@ -83,29 +49,22 @@ class _OnboardingPageState extends ConsumerState { } await onboardingState.createCustomProfile( - displayName: _displayNameController.text.trim(), - description: _descriptionController.text.trim(), + displayName: displayNameState!.displayName.trim(), + description: bioState!.description.trim(), avatar: avatarToUse, ); if (!mounted) return; - // Sync preferences from server before navigating to main - // This ensures feeds are loaded properly after onboarding await ref.read(settingsProvider.notifier).syncPreferencesFromServer(); if (!mounted) return; - // Navigate to main screen context.router.replaceAll([const MainRoute()]); } catch (_) { if (!mounted) return; } finally { - if (mounted) { - setState(() { - _isLoading = false; - }); - } + if (mounted) setState(() => _isLoading = false); } } @@ -115,30 +74,10 @@ class _OnboardingPageState extends ConsumerState { final onboardingStateAsync = ref.watch(onboardingProvider); final notifier = ref.read(onboardingProvider.notifier); - ref.listen>(onboardingProvider, ( - previous, - next, - ) { - if (next.hasValue && next.value != null) { - final stateValue = next.value!; - if (_displayNameController.text.isEmpty && - stateValue.displayName.isNotEmpty) { - _displayNameController.text = stateValue.displayName; - } - if (_descriptionController.text.isEmpty && - stateValue.description.isNotEmpty) { - _descriptionController.text = stateValue.description; - } - } - }); - - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - return Scaffold( - backgroundColor: theme.scaffoldBackgroundColor, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: AppBar( - backgroundColor: theme.scaffoldBackgroundColor, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, elevation: 0, centerTitle: true, title: Text(l10n.pageTitleCompleteProfile), @@ -160,7 +99,6 @@ class _OnboardingPageState extends ConsumerState { ), ), data: (state) { - final hasImportedBskyProfile = state.bskyProfileRecord != null; ImageProvider? avatarImageProvider; if (state.localAvatarBytes != null) { avatarImageProvider = MemoryImage(state.localAvatarBytes!); @@ -177,205 +115,59 @@ class _OnboardingPageState extends ConsumerState { final isAvatarActive = hasLocalAvatar || notifier.currentAvatarDisplayUrl != null; - return Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (hasImportedBskyProfile) ...[ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16), - ), - child: Text( - 'We found your Bluesky profile in your repo and used it to autofill these details. You can change anything here before continuing, and this profile only appears in Spark, not on Bluesky.', - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - const SizedBox(height: 20), - ], - Center( - child: Stack( - alignment: Alignment.bottomRight, - children: [ - GestureDetector( - onTap: notifier.pickAvatar, - child: CircleAvatar( - radius: 50, - backgroundImage: avatarImageProvider, - backgroundColor: - theme.colorScheme.surfaceContainerHighest, - child: avatarImageProvider == null - ? Icon( - Icons.person, - size: 50, - color: theme.colorScheme.onSurfaceVariant, - ) - : null, - ), - ), - Positioned( - right: 0, - bottom: 0, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (hasLocalAvatar) - Padding( - padding: const EdgeInsets.all(4), - child: GestureDetector( - onTap: notifier.revertAvatarToInitial, - child: Container( - decoration: const BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, - ), - padding: const EdgeInsets.all(4), - child: const Icon( - Icons.undo, - size: 16, - color: Colors.white, - ), - ), - ), - ), - if (isAvatarActive) - Padding( - padding: const EdgeInsets.all(4), - child: GestureDetector( - onTap: notifier.clearAvatarSelection, - child: Container( - decoration: const BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, - ), - padding: const EdgeInsets.all(4), - child: const Icon( - Icons.close, - size: 16, - color: Colors.white, - ), - ), - ), - ), - ], - ), - ), - ], - ), - ), - if (state.removeInitialAvatar && hasInitialAvatar) ...[ - const SizedBox(height: 8), - Center( - child: TextButton.icon( - onPressed: notifier.revertAvatarToInitial, - icon: const Icon(Icons.undo), - label: const Text('Use Bluesky avatar'), - ), - ), - ], - const SizedBox(height: 16), - Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - CustomTextField( - controller: _displayNameController, - hintText: l10n.hintDisplayName, - fillColor: - theme.inputDecorationTheme.fillColor ?? - theme.colorScheme.surface, - onUndo: - (state.bskyProfileRecord?.displayName != null && - _displayNameController.text != - (state.bskyProfileRecord?.displayName ?? - '')) - ? notifier.resetDisplayName - : null, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return l10n.inputErrorRequired; - } - if (value.trim().length > 64) { - return 'Display Name cannot exceed ' - '64 characters'; - } - return null; - }, - ), - const SizedBox(height: 12), - CustomTextField( - controller: _descriptionController, - hintText: l10n.hintBio, - fillColor: - theme.inputDecorationTheme.fillColor ?? - theme.colorScheme.surface, - maxLines: 3, - onUndo: - (state.bskyProfileRecord?.description != null && - _descriptionController.text != - (state.bskyProfileRecord?.description ?? - '')) - ? notifier.resetDescription - : null, - validator: (value) { - if (value != null && value.trim().length > 256) { - return 'Bio cannot exceed 256 characters'; - } - return null; - }, - ), - const SizedBox(height: 24), - Align( - alignment: Alignment.centerRight, - child: ElevatedButton( - onPressed: _isLoading - ? null - : _handleCompleteOnboarding, - style: ElevatedButton.styleFrom( - backgroundColor: colorScheme.primary, - foregroundColor: colorScheme.onPrimary, - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 16, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - child: _isLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(l10n.buttonConfirm), - const SizedBox(width: 8), - const Icon(Icons.check), - ], - ), - ), - ), - ], - ), - ), - ], - ), + final steps = [ + OnboardingStep( + title: l10n.onboardingIntroStepTitle, + builder: (context) => const OnboardingWelcomeStep(), + ), + OnboardingStep( + title: l10n.onboardingAvatarStepTitle, + builder: (context) => OnboardingAvatarStep( + hasImportedBskyProfile: state.bskyProfileRecord != null, + avatarImageProvider: avatarImageProvider, + hasLocalAvatar: hasLocalAvatar, + hasInitialAvatar: hasInitialAvatar, + isAvatarActive: isAvatarActive, + onPickAvatar: notifier.pickAvatar, + onRevertAvatar: notifier.revertAvatarToInitial, + onClearAvatar: notifier.clearAvatarSelection, + ), + ), + OnboardingStep( + title: l10n.onboardingNameStepTitle, + builder: (context) => OnboardingDisplayNameStep( + key: _displayNameStepKey, + initialDisplayName: state.displayName, + onUndoDisplayName: + (state.bskyProfileRecord?.displayName != null && + state.displayName != + (state.bskyProfileRecord?.displayName ?? '')) + ? notifier.resetDisplayName + : null, ), + canProceed: () => + _displayNameStepKey.currentState?.validate() ?? false, ), + OnboardingStep( + title: l10n.onboardingBioStepTitle, + builder: (context) => OnboardingBioStep( + key: _bioStepKey, + initialDescription: state.description, + onUndoDescription: + (state.bskyProfileRecord?.description != null && + state.description != + (state.bskyProfileRecord?.description ?? '')) + ? notifier.resetDescription + : null, + ), + canProceed: () => _bioStepKey.currentState?.validate() ?? false, + ), + ]; + + return OnboardingSequence( + steps: steps, + isCompleteLoading: _isLoading, + onComplete: _handleCompleteOnboarding, ); }, ), diff --git a/lib/src/features/posting/ui/widgets/mention_input_field.dart b/lib/src/features/posting/ui/widgets/mention_input_field.dart index 8d51119..aa22e3c 100644 --- a/lib/src/features/posting/ui/widgets/mention_input_field.dart +++ b/lib/src/features/posting/ui/widgets/mention_input_field.dart @@ -3,6 +3,7 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spark/src/core/design_system/components/molecules/input_field.dart'; import 'package:spark/src/core/design_system/tokens/constants.dart'; +import 'package:spark/src/core/ui/widgets/user_avatar.dart'; import 'package:spark/src/core/utils/text_formatter.dart'; import 'package:spark/src/features/posting/models/mention.dart'; import 'package:spark/src/features/posting/models/mention_controller.dart'; @@ -387,14 +388,9 @@ class _MentionInputFieldState extends ConsumerState { final actor = typeaheadState.results[index]; return ListTile( dense: true, - leading: CircleAvatar( - radius: 16, - backgroundImage: actor.avatar != null - ? NetworkImage(actor.avatar.toString()) - : null, - child: actor.avatar == null - ? const Icon(Icons.person, size: 16) - : null, + leading: UserAvatar( + imageUrl: actor.avatar?.toString() ?? '', + size: 32, ), title: Text( actor.displayName ?? actor.handle, diff --git a/lib/src/features/profile/ui/pages/edit_profile_page.dart b/lib/src/features/profile/ui/pages/edit_profile_page.dart index a20eca4..0d80bdd 100644 --- a/lib/src/features/profile/ui/pages/edit_profile_page.dart +++ b/lib/src/features/profile/ui/pages/edit_profile_page.dart @@ -4,9 +4,10 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; -import 'package:sprk_poptart/so/sprk/actor/defs.dart'; import 'package:spark/src/core/ui/widgets/custom_text_field.dart'; +import 'package:spark/src/core/ui/widgets/default_profile_avatar.dart'; import 'package:spark/src/features/profile/providers/edit_profile_provider.dart'; +import 'package:sprk_poptart/so/sprk/actor/defs.dart'; /// Edit profile page that allows users to update their profile information @RoutePage() @@ -170,11 +171,7 @@ class _EditProfilePageState extends ConsumerState { backgroundColor: theme.colorScheme.surfaceContainerHighest, child: avatarImageProvider == null - ? Icon( - Icons.person, - size: 50, - color: theme.colorScheme.onSurfaceVariant, - ) + ? const DefaultProfileAvatar(size: 100) : null, ), ), diff --git a/lib/src/features/search/ui/pages/search_page.dart b/lib/src/features/search/ui/pages/search_page.dart index e5fa8d0..0e189ef 100644 --- a/lib/src/features/search/ui/pages/search_page.dart +++ b/lib/src/features/search/ui/pages/search_page.dart @@ -7,8 +7,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spark/src/core/design_system/components/molecules/input_field.dart'; import 'package:spark/src/core/design_system/templates/explore_page_template.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; -import 'package:sprk_poptart/so/sprk/actor/defs.dart'; import 'package:spark/src/core/routing/app_router.dart'; +import 'package:spark/src/core/ui/widgets/user_avatar.dart'; import 'package:spark/src/features/search/providers/actor_typeahead_provider.dart'; import 'package:spark/src/features/search/providers/actor_typeahead_state.dart'; import 'package:spark/src/features/search/providers/post_search_provider.dart'; @@ -18,6 +18,7 @@ import 'package:spark/src/features/search/ui/pages/post_results.dart'; import 'package:spark/src/features/search/ui/pages/user_results.dart'; import 'package:spark/src/features/search/ui/widgets/suggested_feeds_list.dart'; import 'package:spark/src/features/stories/providers/stories_by_author.dart'; +import 'package:sprk_poptart/so/sprk/actor/defs.dart'; import 'package:spark/src/features/stories/ui/widgets/stories_list.dart'; /// Search page to find users @@ -226,12 +227,9 @@ class _ActorTypeaheadSuggestions extends StatelessWidget { return ListTile( onTap: () => onSuggestionSelected(actor), contentPadding: const EdgeInsets.symmetric(vertical: 4), - leading: CircleAvatar( - radius: 18, - backgroundImage: actor.avatar != null - ? NetworkImage(actor.avatar.toString()) - : null, - child: actor.avatar == null ? const Icon(Icons.person) : null, + leading: UserAvatar( + imageUrl: actor.avatar?.toString() ?? '', + size: 36, ), title: Text(actor.displayName ?? actor.handle), subtitle: Text('@${actor.handle}'), diff --git a/lib/src/features/settings/ui/pages/labeler_label_settings_page.dart b/lib/src/features/settings/ui/pages/labeler_label_settings_page.dart index 13cf6df..17fff41 100644 --- a/lib/src/features/settings/ui/pages/labeler_label_settings_page.dart +++ b/lib/src/features/settings/ui/pages/labeler_label_settings_page.dart @@ -1,18 +1,18 @@ import 'package:poptart/poptart.dart'; import 'package:auto_route/auto_route.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get_it/get_it.dart'; import 'package:spark/src/core/design_system/components/atoms/buttons/app_leading_button.dart'; -import 'package:sprk_poptart/so/sprk/actor/defs.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/network/atproto/data/models/labeler_models.dart'; import 'package:spark/src/core/network/atproto/data/repositories/actor_repository.dart'; import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; -import 'package:spark/src/core/l10n/app_localizations.dart'; +import 'package:spark/src/core/ui/widgets/user_avatar.dart'; import 'package:spark/src/core/utils/logging/logging.dart'; import 'package:spark/src/features/settings/providers/settings_provider.dart'; import 'package:spark/src/features/settings/ui/widgets/widgets.dart'; +import 'package:sprk_poptart/so/sprk/actor/defs.dart'; import 'package:sprk_poptart/so/sprk/labeler/get_services.dart' as sprk_get_services; @@ -407,18 +407,7 @@ class _LabelerLabelSettingsPageState ), child: Row( children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Icon( - Icons.person, - color: colorScheme.onSurface.withAlpha(178), - ), - ), + const UserAvatar(size: 48), const SizedBox(width: 12), Expanded( child: Column( @@ -562,35 +551,9 @@ class _LabelerLabelSettingsPageState child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(100), - child: profile.avatar != null - ? CachedNetworkImage( - imageUrl: profile.avatar!.toString(), - width: 36, - height: 36, - fit: BoxFit.cover, - errorWidget: (context, url, error) => Container( - width: 36, - height: 36, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.person, - size: 20, - color: colorScheme.onSurface.withAlpha(178), - ), - ), - ) - : Container( - width: 36, - height: 36, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.person, - size: 20, - color: colorScheme.onSurface.withAlpha(178), - ), - ), + UserAvatar( + imageUrl: profile.avatar?.toString() ?? '', + size: 36, ), const SizedBox(width: 10), Expanded( diff --git a/lib/src/features/settings/ui/pages/labeler_management_page.dart b/lib/src/features/settings/ui/pages/labeler_management_page.dart index dceaedb..79f46a5 100644 --- a/lib/src/features/settings/ui/pages/labeler_management_page.dart +++ b/lib/src/features/settings/ui/pages/labeler_management_page.dart @@ -1,17 +1,17 @@ import 'package:auto_route/auto_route.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get_it/get_it.dart'; import 'package:spark/src/core/design_system/components/atoms/buttons/app_leading_button.dart'; import 'package:spark/src/core/design_system/components/atoms/icons.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; -import 'package:sprk_poptart/so/sprk/actor/defs.dart'; import 'package:spark/src/core/network/atproto/data/repositories/actor_repository.dart'; import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; import 'package:spark/src/core/routing/app_router.dart'; +import 'package:spark/src/core/ui/widgets/user_avatar.dart'; import 'package:spark/src/core/utils/logging/logging.dart'; import 'package:spark/src/features/settings/providers/settings_provider.dart'; +import 'package:sprk_poptart/so/sprk/actor/defs.dart'; @RoutePage() class LabelerManagementPage extends ConsumerStatefulWidget { @@ -394,35 +394,9 @@ class _LabelerManagementPageState extends ConsumerState child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(100), - child: profile.avatar != null - ? CachedNetworkImage( - imageUrl: profile.avatar!.toString(), - width: 36, - height: 36, - fit: BoxFit.cover, - errorWidget: (context, url, error) => Container( - width: 36, - height: 36, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.person, - size: 20, - color: colorScheme.onSurface.withAlpha(178), - ), - ), - ) - : Container( - width: 36, - height: 36, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.person, - size: 20, - color: colorScheme.onSurface.withAlpha(178), - ), - ), + UserAvatar( + imageUrl: profile.avatar?.toString() ?? '', + size: 36, ), const SizedBox(width: 10), Expanded( @@ -497,19 +471,7 @@ class _LabelerManagementPageState extends ConsumerState padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), child: Row( children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Icon( - Icons.person, - size: 20, - color: colorScheme.onSurface.withAlpha(178), - ), - ), + const UserAvatar(size: 36), const SizedBox(width: 10), Expanded( child: Column( diff --git a/lib/src/features/stories/ui/pages/author_stories_page.dart b/lib/src/features/stories/ui/pages/author_stories_page.dart index 9c62518..63bdec2 100644 --- a/lib/src/features/stories/ui/pages/author_stories_page.dart +++ b/lib/src/features/stories/ui/pages/author_stories_page.dart @@ -1,14 +1,14 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; -import 'package:sprk_poptart/so/sprk/actor/defs.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; import 'package:spark/src/core/routing/app_router.dart'; +import 'package:spark/src/core/ui/widgets/user_avatar.dart'; import 'package:spark/src/features/stories/ui/pages/story_page.dart'; +import 'package:sprk_poptart/so/sprk/actor/defs.dart'; @RoutePage() class AuthorStoriesPage extends ConsumerStatefulWidget { @@ -354,23 +354,9 @@ class _AuthorStoriesPageState extends ConsumerState ), ); }, - child: Container( - width: 40, - height: 40, - decoration: const BoxDecoration( - shape: BoxShape.circle, - ), - child: ClipOval( - child: CachedNetworkImage( - imageUrl: widget.author.avatar.toString(), - fit: BoxFit.cover, - errorWidget: (context, url, error) => - const Icon( - Icons.person, - color: Colors.white, - ), - ), - ), + child: UserAvatar( + imageUrl: widget.author.avatar?.toString() ?? '', + size: 40, ), ), const SizedBox(width: 12), diff --git a/widgetbook/ios/Podfile b/widgetbook/ios/Podfile index 47355b4..8eaadb0 100644 --- a/widgetbook/ios/Podfile +++ b/widgetbook/ios/Podfile @@ -1,5 +1,4 @@ -# Uncomment this line to define a global platform for your project -# platform :ios, '16.0' +platform :ios, '16.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/widgetbook/ios/Podfile.lock b/widgetbook/ios/Podfile.lock index a5cacfa..f39fe13 100644 --- a/widgetbook/ios/Podfile.lock +++ b/widgetbook/ios/Podfile.lock @@ -1,9 +1,6 @@ PODS: - audio_waveforms (0.0.1): - Flutter - - audioplayers_darwin (0.0.1): - - Flutter - - FlutterMacOS - better_player_plus (1.1.2): - Cache (~> 6.0.0) - Flutter @@ -11,29 +8,18 @@ PODS: - HLSCachingReverseProxyServer - PINCache - Cache (6.0.0) - - camera_avfoundation (0.0.1): - - Flutter - Flutter (1.0.0) - - flutter_secure_storage (6.0.0): - - Flutter - - fvp (0.35.1): + - fvp (0.36.1): - Flutter - FlutterMacOS - - mdk (~> 0.35.0) + - mdk (~> 0.36.0) - GCDWebServer (3.5.4): - GCDWebServer/Core (= 3.5.4) - GCDWebServer/Core (3.5.4) - HLSCachingReverseProxyServer (0.1.0): - GCDWebServer (~> 3.5) - PINCache (>= 3.0.1-beta.3) - - image_picker_ios (0.0.1): - - Flutter - - mdk (0.35.0) - - package_info_plus (0.4.5): - - Flutter - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS + - mdk (0.36.0) - PINCache (3.0.4): - PINCache/Arc-exception-safe (= 3.0.4) - PINCache/Core (= 3.0.4) @@ -42,45 +28,18 @@ PODS: - PINCache/Core (3.0.4): - PINOperation (~> 1.2.3) - PINOperation (1.2.3) - - PostHog (3.35.0) - - posthog_flutter (0.0.1): + - pro_image_editor (12.0.8): - Flutter - - FlutterMacOS - - PostHog (< 4.0.0, >= 3.32.0) - pro_video_editor (0.0.1): - Flutter - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - sqflite_darwin (0.0.4): - - Flutter - - FlutterMacOS - - url_launcher_ios (0.0.1): - - Flutter - - video_player_avfoundation (0.0.1): - - Flutter - - FlutterMacOS - - wakelock_plus (0.0.1): - - Flutter DEPENDENCIES: - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) - - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) - better_player_plus (from `.symlinks/plugins/better_player_plus/ios`) - - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) - Flutter (from `Flutter`) - - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - fvp (from `.symlinks/plugins/fvp/darwin`) - - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - - posthog_flutter (from `.symlinks/plugins/posthog_flutter/ios`) + - pro_image_editor (from `.symlinks/plugins/pro_image_editor/ios`) - pro_video_editor (from `.symlinks/plugins/pro_video_editor/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) - - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) SPEC REPOS: trunk: @@ -90,70 +49,35 @@ SPEC REPOS: - mdk - PINCache - PINOperation - - PostHog EXTERNAL SOURCES: audio_waveforms: :path: ".symlinks/plugins/audio_waveforms/ios" - audioplayers_darwin: - :path: ".symlinks/plugins/audioplayers_darwin/darwin" better_player_plus: :path: ".symlinks/plugins/better_player_plus/ios" - camera_avfoundation: - :path: ".symlinks/plugins/camera_avfoundation/ios" Flutter: :path: Flutter - flutter_secure_storage: - :path: ".symlinks/plugins/flutter_secure_storage/ios" fvp: :path: ".symlinks/plugins/fvp/darwin" - image_picker_ios: - :path: ".symlinks/plugins/image_picker_ios/ios" - package_info_plus: - :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" - posthog_flutter: - :path: ".symlinks/plugins/posthog_flutter/ios" + pro_image_editor: + :path: ".symlinks/plugins/pro_image_editor/ios" pro_video_editor: :path: ".symlinks/plugins/pro_video_editor/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - sqflite_darwin: - :path: ".symlinks/plugins/sqflite_darwin/darwin" - url_launcher_ios: - :path: ".symlinks/plugins/url_launcher_ios/ios" - video_player_avfoundation: - :path: ".symlinks/plugins/video_player_avfoundation/darwin" - wakelock_plus: - :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf - audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd better_player_plus: 3d40145c650bb83dde08f0d593b21a144196769f Cache: 4ca7e00363fca5455f26534e5607634c820ffc2d - camera_avfoundation: 5675ca25298b6f81fa0a325188e7df62cc217741 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 - fvp: 5a47a325ffa8d63e70a276a6436416ae703206a5 + fvp: f8b3b61cf8c696ecaa5608ae0d282ba710e65ae9 GCDWebServer: 2c156a56c8226e2d5c0c3f208a3621ccffbe3ce4 HLSCachingReverseProxyServer: 59935e1e0244ad7f3375d75b5ef46e8eb26ab181 - image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 - mdk: baa616b93f696c7066df0e5ebe057badfa9c462b - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + mdk: 3bfb53e0bcc9643180eaa864360051f281e4c17b PINCache: d9a87a0ff397acffe9e2f0db972ac14680441158 PINOperation: fb563bcc9c32c26d6c78aaff967d405aa2ee74a7 - PostHog: da83ef8bee8c21ccb2b3680054e22c85d352f142 - posthog_flutter: 9535ac2d4ab65ccb9ace3886dcc0b3105198bad5 + pro_image_editor: 3dedac450f82a389877286fa9eb08852cefb04ea pro_video_editor: 44ef9a6d48dbd757ed428cf35396dd05f35c7830 - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b - video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a - wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 -PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e +PODFILE CHECKSUM: bd29822c3d5baf6b44b726f00ea3293a19339ef2 COCOAPODS: 1.16.2 diff --git a/widgetbook/ios/Runner.xcodeproj/project.pbxproj b/widgetbook/ios/Runner.xcodeproj/project.pbxproj index a747b97..7f2c60c 100644 --- a/widgetbook/ios/Runner.xcodeproj/project.pbxproj +++ b/widgetbook/ios/Runner.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; A7B551FDDD3B1859739E2ADB /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6A09F273B323081C2429F5E3 /* Pods_Runner.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -65,6 +66,7 @@ C1F83B00BA082E6C55D3447E /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E66A3276723A81A2130301C3 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; EDC57EE11678391C675F78EC /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -80,6 +82,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, A7B551FDDD3B1859739E2ADB /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -112,6 +115,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -188,6 +192,9 @@ productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -213,6 +220,9 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; @@ -723,6 +733,18 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/widgetbook/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/widgetbook/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..5d8c419 --- /dev/null +++ b/widgetbook/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,131 @@ +{ + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "d10045cace0b4c335c4efa8f7df7e9a9fc5a7c60", + "version" : "12.13.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "19dffda9a9caf8d86570ff846535902d8509d7bf", + "version" : "3.5.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "c2c76bebcfbb90d90ea10599f934f9af160e1604", + "version" : "12.13.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", + "version" : "5.3.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "posthog-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/PostHog/posthog-ios", + "state" : { + "revision" : "cf6155963a6532c7b88e0cfa2b1d07595d6b6db6", + "version" : "3.58.3" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + } + ], + "version" : 2 +} diff --git a/widgetbook/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/widgetbook/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d4..c3fedb2 100644 --- a/widgetbook/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/widgetbook/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/widgetbook/ios/Runner/Info.plist b/widgetbook/ios/Runner/Info.plist index 146e7bf..c2b8376 100644 --- a/widgetbook/ios/Runner/Info.plist +++ b/widgetbook/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,29 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +66,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/widgetbook/lib/features/auth/onboarding_avatar_step.dart b/widgetbook/lib/features/auth/onboarding_avatar_step.dart new file mode 100644 index 0000000..249a220 --- /dev/null +++ b/widgetbook/lib/features/auth/onboarding_avatar_step.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart'; + +import 'package:spark/src/features/auth/ui/onboarding/steps/onboarding_avatar_step.dart'; + +@UseCase(name: 'default', type: OnboardingAvatarStep) +Widget buildOnboardingAvatarStepUseCase(BuildContext context) { + final hasAvatar = context.knobs.boolean( + label: 'Has Avatar', + initialValue: true, + ); + final hasBskyProfile = context.knobs.boolean( + label: 'Has Imported Bluesky Profile', + initialValue: true, + ); + + return OnboardingAvatarStep( + hasImportedBskyProfile: hasBskyProfile, + avatarImageProvider: hasAvatar + ? const NetworkImage('https://placehold.co/300x300') + : null, + hasLocalAvatar: context.knobs.boolean( + label: 'Has Local Avatar', + initialValue: false, + ), + hasInitialAvatar: hasAvatar, + isAvatarActive: hasAvatar, + onPickAvatar: () {}, + onRevertAvatar: () {}, + onClearAvatar: () {}, + ); +} diff --git a/widgetbook/lib/features/auth/onboarding_bio_step.dart b/widgetbook/lib/features/auth/onboarding_bio_step.dart new file mode 100644 index 0000000..0f7b82a --- /dev/null +++ b/widgetbook/lib/features/auth/onboarding_bio_step.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart'; + +import 'package:spark/src/features/auth/ui/onboarding/steps/onboarding_bio_step.dart'; + +@UseCase(name: 'default', type: OnboardingBioStep) +Widget buildOnboardingBioStepUseCase(BuildContext context) { + return OnboardingBioStep( + initialDescription: context.knobs.string( + label: 'Bio', + initialValue: 'Building weird internet things.', + ), + onUndoDescription: + context.knobs.boolean(label: 'Show Undo', initialValue: true) + ? () {} + : null, + ); +} diff --git a/widgetbook/lib/features/auth/onboarding_display_name_step.dart b/widgetbook/lib/features/auth/onboarding_display_name_step.dart new file mode 100644 index 0000000..b9ded29 --- /dev/null +++ b/widgetbook/lib/features/auth/onboarding_display_name_step.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart'; + +import 'package:spark/src/features/auth/ui/onboarding/steps/onboarding_display_name_step.dart'; + +@UseCase(name: 'default', type: OnboardingDisplayNameStep) +Widget buildOnboardingDisplayNameStepUseCase(BuildContext context) { + return OnboardingDisplayNameStep( + initialDisplayName: context.knobs.string( + label: 'Display Name', + initialValue: 'Jane Doe', + ), + onUndoDisplayName: + context.knobs.boolean(label: 'Show Undo', initialValue: true) + ? () {} + : null, + ); +} diff --git a/widgetbook/lib/features/auth/onboarding_sequence.dart b/widgetbook/lib/features/auth/onboarding_sequence.dart new file mode 100644 index 0000000..23223cb --- /dev/null +++ b/widgetbook/lib/features/auth/onboarding_sequence.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart'; + +import 'package:spark/src/features/auth/ui/onboarding/onboarding_sequence.dart'; +import 'package:spark/src/features/auth/ui/onboarding/onboarding_step.dart'; +import 'package:spark/src/features/auth/ui/onboarding/steps/onboarding_avatar_step.dart'; +import 'package:spark/src/features/auth/ui/onboarding/steps/onboarding_bio_step.dart'; +import 'package:spark/src/features/auth/ui/onboarding/steps/onboarding_display_name_step.dart'; +import 'package:spark/src/features/auth/ui/onboarding/steps/onboarding_welcome_step.dart'; + +@UseCase(name: 'full_sequence', type: OnboardingSequence) +Widget buildOnboardingSequenceUseCase(BuildContext context) { + final initialIndex = context.knobs.int.slider( + label: 'Initial Step Index', + initialValue: 0, + min: 0, + max: 3, + ); + + return OnboardingSequence( + initialIndex: initialIndex, + steps: const [ + OnboardingStep(title: 'Set up your profile', builder: _buildWelcomeStep), + OnboardingStep(title: 'Add a profile photo', builder: _buildAvatarStep), + OnboardingStep( + title: 'Add your display name', + builder: _buildDisplayNameStep, + ), + OnboardingStep(title: 'Add a bio', builder: _buildBioStep), + ], + onComplete: () { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Onboarding complete!'))); + }, + ); +} + +Widget _buildWelcomeStep(BuildContext context) { + return const OnboardingWelcomeStep(); +} + +Widget _buildAvatarStep(BuildContext context) { + return OnboardingAvatarStep( + hasImportedBskyProfile: false, + onPickAvatar: () {}, + onRevertAvatar: () {}, + onClearAvatar: () {}, + ); +} + +Widget _buildDisplayNameStep(BuildContext context) { + return const OnboardingDisplayNameStep(initialDisplayName: 'Jane Doe'); +} + +Widget _buildBioStep(BuildContext context) { + return const OnboardingBioStep( + initialDescription: 'Building weird internet things.', + ); +} diff --git a/widgetbook/lib/features/auth/onboarding_welcome_step.dart b/widgetbook/lib/features/auth/onboarding_welcome_step.dart new file mode 100644 index 0000000..a512490 --- /dev/null +++ b/widgetbook/lib/features/auth/onboarding_welcome_step.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart'; + +import 'package:spark/src/features/auth/ui/onboarding/steps/onboarding_welcome_step.dart'; + +@UseCase(name: 'default', type: OnboardingWelcomeStep) +Widget buildOnboardingWelcomeStepUseCase(BuildContext context) { + return const OnboardingWelcomeStep(); +} diff --git a/widgetbook/lib/widgetbook.dart b/widgetbook/lib/widgetbook.dart index 1e3a53a..759eec1 100644 --- a/widgetbook/lib/widgetbook.dart +++ b/widgetbook/lib/widgetbook.dart @@ -2,9 +2,19 @@ import 'package:flutter/material.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; import 'package:spark/src/core/design_system/theme/app_theme.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; import 'widgetbook.directories.g.dart'; +Widget _appBuilder(BuildContext context, Widget child) { + return MaterialApp( + debugShowCheckedModeBanner: false, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Material(child: child), + ); +} + @widgetbook.App() class WidgetbookApp extends StatelessWidget { const WidgetbookApp({super.key}); @@ -14,6 +24,7 @@ class WidgetbookApp extends StatelessWidget { return Widgetbook.material( // Use the generated directories variable directories: directories, + appBuilder: _appBuilder, addons: [ MaterialThemeAddon( themes: [