diff --git a/lib/src/core/utils/logging/logger_factory.dart b/lib/src/core/utils/logging/logger_factory.dart --- 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 @@ /// 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 @@ _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 --- a/lib/src/features/auth/providers/onboarding_notifier.dart +++ b/lib/src/features/auth/providers/onboarding_notifier.dart @@ -38,11 +38,8 @@ } 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 @@ 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 @@ 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, + ), + ); } } @@ -132,6 +149,10 @@ if (currentVal == null) return null; if (currentVal.localAvatarBytes != null) { + return null; + } + + if (currentVal.removeInitialAvatar) { return null; } @@ -156,6 +177,7 @@ Uint8List? avatarBytes, String? initialAvatarUrl, String? initialAvatarCid, + bool removeInitialAvatar, })? getOnboardingDataForNextStep() { if (!state.hasValue) return null; @@ -166,6 +188,7 @@ avatarBytes: current.localAvatarBytes, initialAvatarUrl: current.initialAvatarUrl, initialAvatarCid: current.initialAvatarCid, + removeInitialAvatar: current.removeInitialAvatar, ); } } diff --git a/lib/src/core/auth/data/models/onboarding_screen_state.dart b/lib/src/core/auth/data/models/onboarding_screen_state.dart --- 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 @@ 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 --- a/lib/src/core/auth/data/repositories/onboarding_repository.dart +++ b/lib/src/core/auth/data/repositories/onboarding_repository.dart @@ -8,6 +8,9 @@ /// 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 --- a/lib/src/core/auth/data/repositories/onboarding_repository_impl.dart +++ b/lib/src/core/auth/data/repositories/onboarding_repository_impl.dart @@ -56,14 +56,60 @@ @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; } } diff --git a/lib/src/features/auth/ui/pages/onboarding_page.dart b/lib/src/features/auth/ui/pages/onboarding_page.dart --- a/lib/src/features/auth/ui/pages/onboarding_page.dart +++ b/lib/src/features/auth/ui/pages/onboarding_page.dart @@ -77,7 +77,8 @@ 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 @@ ), ), data: (state) { + final hasImportedBskyProfile = state.bskyProfileRecord != null; ImageProvider? avatarImageProvider; if (state.localAvatarBytes != null) { avatarImageProvider = MemoryImage(state.localAvatarBytes!); @@ -169,6 +171,9 @@ } 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 @@ 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 @@ ], ), ), + 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,