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 @@
+
+
\ 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