mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
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>
160 lines
5.4 KiB
Dart
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,
|
|
};
|