Files
buzz/mobile/lib/features/profile/profile_avatar_draft.dart
klopez4212 f7708d0694 Polish mobile onboarding and profile setup (#8438)
Originally stacked on #8375, now merged. Rebased onto main.

Polishes the onboarding landing page, community-link entry, and recovery
sheets. Profile setup uses a consistent sheet with a larger avatar,
inline display name, and draft photo/emoji/animated editing; iOS uses
native controls and coordinated avatar/sheet transitions.

Validation: mobile analysis, full tests, and normal pre-push checks
passed after rebase. Prior iOS build and device walkthrough passed;
native validation was not repeated after this rebase. Snapshots omitted
as requested.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
2026-10-22 17:27:38 +00:00

160 lines
5.4 KiB
Dart

import 'dart:typed_data';
import '../../shared/animated_avatar.dart';
import '../../shared/relay/relay.dart';
/// A prepared profile-avatar change that is uploaded only when the user saves.
sealed class ProfileAvatarDraft {
/// Creates a prepared profile-avatar draft.
const ProfileAvatarDraft();
/// Returns the avatar URL for this draft using [service] when upload is
/// required.
///
/// Implementations cache successful uploads for the same service so a
/// profile-publish retry does not create duplicate media. Failed uploads may
/// be retried, and changing services starts a new upload for that community.
Future<String> upload(MediaUploadService service);
}
/// An avatar draft that already has its final URL and needs no media upload.
final class ProfileUrlAvatarDraft extends ProfileAvatarDraft {
/// Creates a draft backed by [url].
const ProfileUrlAvatarDraft(this.url);
/// The URL that will be written to the profile.
final String url;
@override
Future<String> upload(MediaUploadService service) async => url;
}
/// A locally prepared still image awaiting upload on Save.
final class ProfileImageAvatarDraft extends ProfileAvatarDraft {
/// Creates a still-image draft from JPEG [bytes].
ProfileImageAvatarDraft(this.bytes);
/// The prepared JPEG payload.
final Uint8List bytes;
MediaUploadService? _uploadService;
Future<String>? _uploadedUrl;
@override
Future<String> upload(MediaUploadService service) async {
if (!identical(_uploadService, service)) {
_uploadService = service;
_uploadedUrl = null;
}
final existing = _uploadedUrl;
if (existing != null) return existing;
final upload = service
.uploadBytes(bytes, mimeType: 'image/jpeg')
.then((descriptor) => descriptor.url);
_uploadedUrl = upload;
try {
return await upload;
} catch (_) {
if (identical(_uploadedUrl, upload)) _uploadedUrl = null;
rethrow;
}
}
}
/// A locally prepared animated avatar and its still poster awaiting upload.
final class ProfileAnimatedAvatarDraft extends ProfileAvatarDraft {
/// Creates an animated draft from PNG [animation] and [poster] payloads.
ProfileAnimatedAvatarDraft({required this.animation, required this.poster});
/// The animated PNG payload.
final Uint8List animation;
/// The still PNG poster shown when animation is unavailable or disabled.
final Uint8List poster;
MediaUploadService? _uploadService;
Future<String>? _uploadedUrl;
Future<BlobDescriptor>? _posterUpload;
Future<BlobDescriptor>? _animationUpload;
Future<BlobDescriptor> _uploadPoster(MediaUploadService service) {
final existing = _posterUpload;
if (existing != null) return existing;
late final Future<BlobDescriptor> upload;
upload = service.uploadBytes(poster, mimeType: 'image/png').catchError((
Object error,
StackTrace stackTrace,
) {
if (identical(_posterUpload, upload)) _posterUpload = null;
Error.throwWithStackTrace(error, stackTrace);
});
_posterUpload = upload;
return upload;
}
Future<BlobDescriptor> _uploadAnimation(MediaUploadService service) {
final existing = _animationUpload;
if (existing != null) return existing;
late final Future<BlobDescriptor> upload;
upload = service.uploadBytes(animation, mimeType: 'image/png').catchError((
Object error,
StackTrace stackTrace,
) {
if (identical(_animationUpload, upload)) _animationUpload = null;
Error.throwWithStackTrace(error, stackTrace);
});
_animationUpload = upload;
return upload;
}
@override
Future<String> upload(MediaUploadService service) async {
if (!identical(_uploadService, service)) {
_uploadService = service;
_uploadedUrl = null;
_posterUpload = null;
_animationUpload = null;
}
final existing = _uploadedUrl;
if (existing != null) return existing;
// Cache each content-addressed part independently. If one request fails,
// retry only that part so a successful counterpart remains attached to
// this draft instead of becoming an abandoned duplicate.
final upload = Future.wait(
[_uploadPoster(service), _uploadAnimation(service)],
).then((uploads) => buildAnimatedAvatarUrl(uploads[0].url, uploads[1].url));
_uploadedUrl = upload;
try {
return await upload;
} catch (_) {
if (identical(_uploadedUrl, upload)) _uploadedUrl = null;
rethrow;
}
}
}
/// Transfers only local draft data between the app and its native sheet engine.
Map<String, Object?>? encodeProfileAvatarDraft(ProfileAvatarDraft? draft) =>
switch (draft) {
ProfileImageAvatarDraft(:final bytes) => {
'type': 'image',
'bytes': bytes,
},
ProfileAnimatedAvatarDraft(:final animation, :final poster) => {
'type': 'animated',
'animation': animation,
'poster': poster,
},
ProfileUrlAvatarDraft(:final url) => {'type': 'url', 'url': url},
null => null,
};
ProfileAvatarDraft? decodeProfileAvatarDraft(Map<dynamic, dynamic>? value) =>
switch (value?['type']) {
'image' => ProfileImageAvatarDraft(value!['bytes'] as Uint8List),
'animated' => ProfileAnimatedAvatarDraft(
animation: value!['animation'] as Uint8List,
poster: value['poster'] as Uint8List,
),
'url' => ProfileUrlAvatarDraft(value!['url'] as String),
_ => null,
};