From 41681228efea8afe234aa8b7299afd52c812e9ed Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Sun, 12 Apr 2026 11:35:29 -0400 Subject: [PATCH] feat: bluesky profile in onboarding --- .../data/models/onboarding_screen_state.dart | 1 + .../repositories/onboarding_repository.dart | 3 ++ .../onboarding_repository_impl.dart | 52 +++++++++++++++++-- .../core/utils/logging/logger_factory.dart | 8 ++- .../auth/providers/onboarding_notifier.dart | 37 ++++++++++--- .../auth/ui/pages/onboarding_page.dart | 33 +++++++++++- 6 files changed, 121 insertions(+), 13 deletions(-) diff --git a/lib/src/core/auth/data/models/onboarding_screen_state.dart b/lib/src/core/auth/data/models/onboarding_screen_state.dart index 70dbc91..cd3ce5a 100644 --- a/lib/src/core/auth/data/models/onboarding_screen_state.dart +++ b/lib/src/core/auth/data/models/onboarding_screen_state.dart @@ -13,6 +13,7 @@ abstract class OnboardingScreenState with _$OnboardingScreenState { String? initialAvatarCid, String? initialAvatarUrl, Uint8List? localAvatarBytes, + @Default(false) bool removeInitialAvatar, @Default('') String displayName, @Default('') String description, String? errorMessage, diff --git a/lib/src/core/auth/data/repositories/onboarding_repository.dart b/lib/src/core/auth/data/repositories/onboarding_repository.dart index 2d9ae77..c9c7ffb 100644 --- a/lib/src/core/auth/data/repositories/onboarding_repository.dart +++ b/lib/src/core/auth/data/repositories/onboarding_repository.dart @@ -8,6 +8,9 @@ abstract class OnboardingRepository { /// Retrieves the Bluesky profile for import Future getBskyProfile(); + /// Retrieves the resolved Bluesky avatar URL for the current user. + Future getBskyAvatarUrl(); + /// Creates a Spark actor profile with custom values Future createSparkProfile({ required String displayName, diff --git a/lib/src/core/auth/data/repositories/onboarding_repository_impl.dart b/lib/src/core/auth/data/repositories/onboarding_repository_impl.dart index 07b91cd..633ea02 100644 --- a/lib/src/core/auth/data/repositories/onboarding_repository_impl.dart +++ b/lib/src/core/auth/data/repositories/onboarding_repository_impl.dart @@ -56,18 +56,64 @@ class OnboardingRepositoryImpl implements OnboardingRepository { @override Future getBskyProfile() async { - if (_did == null) return null; + await _authRepository.initializationComplete; + + if (_did == null || _did!.isEmpty) return null; try { + final atproto = _atproto; + if (atproto == null) { + _logger.w('AtProto not initialized while fetching Bluesky profile'); + return null; + } + final uri = AtUri.parse('at://$_did/app.bsky.actor.profile/self'); - final response = await _repoRepository.getRecord(uri: uri); - return ActorProfileRecord.fromJson(response.record.toJson()); + final response = await atproto.repo.getRecord( + repo: uri.hostname, + collection: uri.collection.toString(), + rkey: uri.rkey, + ); + + return ActorProfileRecord.fromJson(response.data.value); } catch (e) { _logger.i('Bluesky profile not found', error: e); return null; } } + @override + Future getBskyAvatarUrl() async { + await _authRepository.initializationComplete; + + if (_did == null || _did!.isEmpty) return null; + + try { + final atproto = _atproto; + if (atproto == null) { + _logger.w('AtProto not initialized while fetching Bluesky avatar URL'); + return null; + } + + final oauthSession = atproto.oAuthSession; + if (oauthSession == null) { + _logger.w('OAuth session missing while fetching Bluesky avatar URL'); + return null; + } + + final bluesky = bs.Bluesky.fromOAuthSession(oauthSession); + final profile = await bluesky.actor.getProfile(actor: _did!); + + return profile.data.avatar; + } catch (e, s) { + _logger.i( + 'Failed to resolve Bluesky avatar URL', + error: e, + stackTrace: s, + ); + return null; + } + } + @override Future createSparkProfile({ required String displayName, diff --git a/lib/src/core/utils/logging/logger_factory.dart b/lib/src/core/utils/logging/logger_factory.dart index eca6a3c..e1b7c1f 100644 --- a/lib/src/core/utils/logging/logger_factory.dart +++ b/lib/src/core/utils/logging/logger_factory.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart'; import 'package:spark/src/core/utils/logging/console_output.dart'; @@ -11,10 +13,12 @@ class LoggerFactory { /// Global minimum log level static LogLevel _globalMinLevel = LogLevel.warning; + static bool get _supportsFileLogging => !kIsWeb && !Platform.isIOS; + /// List of default outputs static final List _defaultOutputs = [ ConsoleOutput(), - if (!kIsWeb) FileOutput(), + if (_supportsFileLogging) FileOutput(), ]; /// Map of logger instances by name @@ -69,7 +73,7 @@ class LoggerFactory { _defaultOutputs ..clear() ..add(ConsoleOutput()); - if (!kIsWeb) { + if (_supportsFileLogging) { _defaultOutputs.add(FileOutput()); } _globalMinLevel = LogLevel.warning; diff --git a/lib/src/features/auth/providers/onboarding_notifier.dart b/lib/src/features/auth/providers/onboarding_notifier.dart index bf7b772..0fa55d1 100644 --- a/lib/src/features/auth/providers/onboarding_notifier.dart +++ b/lib/src/features/auth/providers/onboarding_notifier.dart @@ -38,11 +38,8 @@ class OnboardingNotifier extends _$OnboardingNotifier { } final profileDataMap = await _onboardingRepository.getBskyProfile(); - final avatarCid = profileDataMap?.avatar?.ref.link; - final avatarUrl = avatarCid != null && avatarCid.isNotEmpty - ? 'https://cdn.bsky.app/img/avatar/plain/$userDid/$avatarCid@jpeg' - : null; + final avatarUrl = await _onboardingRepository.getBskyAvatarUrl(); return OnboardingScreenState( isLoading: false, @@ -91,7 +88,12 @@ class OnboardingNotifier extends _$OnboardingNotifier { final pickedFile = await picker.pickImage(source: ImageSource.gallery); if (pickedFile != null && state.hasValue) { final bytes = await pickedFile.readAsBytes(); - state = AsyncValue.data(state.value!.copyWith(localAvatarBytes: bytes)); + state = AsyncValue.data( + state.value!.copyWith( + localAvatarBytes: bytes, + removeInitialAvatar: false, + ), + ); } } @@ -117,13 +119,28 @@ class OnboardingNotifier extends _$OnboardingNotifier { void revertAvatarToInitial() { if (state.hasValue) { - state = AsyncValue.data(state.value!.copyWith(localAvatarBytes: null)); + state = AsyncValue.data( + state.value!.copyWith( + localAvatarBytes: null, + removeInitialAvatar: false, + ), + ); } } void clearAvatarSelection() { if (state.hasValue) { - state = AsyncValue.data(state.value!.copyWith(localAvatarBytes: null)); + final current = state.value!; + final hasInitialAvatar = + (current.initialAvatarUrl?.isNotEmpty ?? false) || + (current.initialAvatarCid?.isNotEmpty ?? false); + + state = AsyncValue.data( + current.copyWith( + localAvatarBytes: null, + removeInitialAvatar: hasInitialAvatar, + ), + ); } } @@ -135,6 +152,10 @@ class OnboardingNotifier extends _$OnboardingNotifier { return null; } + if (currentVal.removeInitialAvatar) { + return null; + } + if (currentVal.initialAvatarUrl != null && currentVal.initialAvatarUrl!.isNotEmpty) { return currentVal.initialAvatarUrl; @@ -156,6 +177,7 @@ class OnboardingNotifier extends _$OnboardingNotifier { Uint8List? avatarBytes, String? initialAvatarUrl, String? initialAvatarCid, + bool removeInitialAvatar, })? getOnboardingDataForNextStep() { if (!state.hasValue) return null; @@ -166,6 +188,7 @@ class OnboardingNotifier extends _$OnboardingNotifier { avatarBytes: current.localAvatarBytes, initialAvatarUrl: current.initialAvatarUrl, initialAvatarCid: current.initialAvatarCid, + removeInitialAvatar: current.removeInitialAvatar, ); } } diff --git a/lib/src/features/auth/ui/pages/onboarding_page.dart b/lib/src/features/auth/ui/pages/onboarding_page.dart index 268fc0c..8978322 100644 --- a/lib/src/features/auth/ui/pages/onboarding_page.dart +++ b/lib/src/features/auth/ui/pages/onboarding_page.dart @@ -77,7 +77,8 @@ class _OnboardingPageState extends ConsumerState { final currentState = ref.read(onboardingProvider).value; if (currentState?.localAvatarBytes != null) { avatarToUse = currentState!.localAvatarBytes; - } else if (currentState?.bskyProfileRecord?.avatar != null) { + } else if (currentState?.removeInitialAvatar != true && + currentState?.bskyProfileRecord?.avatar != null) { avatarToUse = currentState!.bskyProfileRecord!.avatar; } @@ -159,6 +160,7 @@ class _OnboardingPageState extends ConsumerState { ), ), data: (state) { + final hasImportedBskyProfile = state.bskyProfileRecord != null; ImageProvider? avatarImageProvider; if (state.localAvatarBytes != null) { avatarImageProvider = MemoryImage(state.localAvatarBytes!); @@ -169,6 +171,9 @@ class _OnboardingPageState extends ConsumerState { } final hasLocalAvatar = state.localAvatarBytes != null; + final hasInitialAvatar = + (state.initialAvatarUrl?.isNotEmpty ?? false) || + (state.initialAvatarCid?.isNotEmpty ?? false); final isAvatarActive = hasLocalAvatar || notifier.currentAvatarDisplayUrl != null; @@ -180,6 +185,22 @@ class _OnboardingPageState extends ConsumerState { 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, @@ -250,6 +271,16 @@ class _OnboardingPageState extends ConsumerState { ], ), ), + 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, -- 2.51.2