mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
## Summary Polish mobile Home and Solo: group Search/Inbox beside a separate profile avatar, move Inbox and Settings to bottom-up pages with Close controls, and place tabs opposite the plus/compose action. Morph plus to compose clockwise when entering Solo and reverse counterclockwise when returning Home, with a directional quarter-turn of the icon inside a level button and a subtle scale pulse, respecting reduced motion. Align Search’s Close control with Inbox, simplify its placeholder, and initialize selected filter contrast correctly. Collapse the plus menu when leaving Home. Center Solo’s native-glass New chat empty state and keep it stationary behind the keyboard. Standardize Tabler icons and neutral backgrounds, retain SF Back/Close on iOS, use native Liquid Glass headers, tab container, and actions (solid grey selected tab, slightly darker on iOS) on iOS and black actions on Android, and smooth the expanded menu corners. Repair native header safe-area/return layout and compose movement after cancellation. ### Related issue None found for this UI refinement; follows #8337. ### Testing - Formatting, analysis, full mobile suite: 3,487 passed, 4 skipped; 3 push-configuration tests passed. File-size checks passed. - Base UI built and installed on Pixel and iPhone in place. Pixel navigation/New chat cancellation checked. Subsequent glass and rotation refinements are previewed on iPhone; motion feel awaits human confirmation. - Draft pending human confirmation of the final Settings entrance/return behavior on iPhone. ### Pixel snapshots | Solo | Expanded actions | | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-solo.png" width="280" alt="Solo empty state with New chat" /> | <img src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-actions.png" width="280" alt="Expanded channel actions with smooth corners" /> | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
758 lines
27 KiB
Dart
758 lines
27 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:buzz/shared/theme/buzz_icons.dart';
|
|
|
|
import '../../shared/emoji/emoji_avatar.dart';
|
|
import '../../shared/emoji/emoji_data.dart';
|
|
import '../../shared/emoji/emoji_data_provider.dart';
|
|
import '../../shared/emoji/emoji_search.dart';
|
|
import '../../shared/emoji/native_emoji_glyph.dart';
|
|
import '../../shared/relay/relay.dart';
|
|
import '../../shared/theme/theme.dart';
|
|
import '../../shared/widgets/buzz_loading_indicator.dart';
|
|
import '../../shared/widgets/frosted_app_bar.dart';
|
|
import '../../shared/widgets/ios_native_segmented_control.dart';
|
|
import '../../shared/widgets/ios_glass_navigation_button.dart';
|
|
import '../../shared/widgets/ios_native_skin_tone_control.dart';
|
|
import '../../shared/widgets/playing_avatar_image.dart';
|
|
import 'animated_avatar_capture.dart';
|
|
import 'camera_disposal_barrier.dart';
|
|
import 'avatar_background_grid.dart';
|
|
import 'avatar_editor_option_button.dart';
|
|
import 'emoji_avatar_tile.dart';
|
|
import 'image_avatar_capture.dart';
|
|
import 'profile_avatar_crop_page.dart';
|
|
import 'profile_avatar_draft.dart';
|
|
|
|
part 'profile_avatar_editor/emoji_avatar_picker.dart';
|
|
|
|
/// Avatar kinds shared with the desktop profile editor.
|
|
enum ProfileAvatarMode {
|
|
/// A still image selected from the camera or photo library.
|
|
image,
|
|
|
|
/// A system emoji composited over a selected background color.
|
|
emoji,
|
|
|
|
/// A short camera animation with framing and background controls.
|
|
animated,
|
|
}
|
|
|
|
/// Builds the animated capture surface for the profile avatar editor.
|
|
typedef AnimatedAvatarCaptureBuilder =
|
|
Widget Function({
|
|
required double height,
|
|
required ValueChanged<Future<ProfileAvatarDraft?> Function()?>
|
|
onPrepareChanged,
|
|
});
|
|
|
|
const _previewSize = 220.0;
|
|
const _motionDuration = Duration(milliseconds: 150);
|
|
const _previewSquishDuration = Duration(milliseconds: 200);
|
|
const Curve _entranceCurve = Curves.easeOutCubic;
|
|
const Curve _exitCurve = Curves.easeInCubic;
|
|
const Curve _modeTransitionCurve = Cubic(0.22, 1, 0.36, 1);
|
|
const double _modeTransitionDistance = 24;
|
|
const double _previewBlockSize = 228;
|
|
const double _modeControlHeight = 40;
|
|
const double _editorControlsBottom = Grid.xl + Grid.xxs;
|
|
const double _editorRailHeight = 88;
|
|
const double _previewControlGap = Grid.twelve;
|
|
const double _emojiPickerPreviewShift = 140;
|
|
// Settings' avatar center sits 96dp below its standard app bar, including its
|
|
// lower 8dp app-bar rail. The expanded editor preview is centred on screen.
|
|
const double _settingsAvatarCenterBelowAppBar = 96;
|
|
|
|
/// In-page profile avatar editor used on Android and iOS.
|
|
class ProfileAvatarEditor extends HookConsumerWidget {
|
|
/// Creates an avatar editor backed by the current profile and draft state.
|
|
const ProfileAvatarEditor({
|
|
super.key,
|
|
required this.currentAvatarUrl,
|
|
required this.fallbackInitial,
|
|
required this.draft,
|
|
required this.mode,
|
|
required this.transition,
|
|
required this.onModeChanged,
|
|
required this.onDraftChanged,
|
|
required this.onAnimatedPrepareChanged,
|
|
required this.onImageCameraActiveChanged,
|
|
this.animatedCaptureBuilder,
|
|
this.imageCaptureBuilder,
|
|
});
|
|
|
|
/// The avatar URL shown until the user selects a new draft.
|
|
final String? currentAvatarUrl;
|
|
|
|
/// The text initial used when [currentAvatarUrl] has no displayable image.
|
|
final String fallbackInitial;
|
|
|
|
/// The unsaved avatar selection for the active editing session.
|
|
final ProfileAvatarDraft? draft;
|
|
|
|
/// The currently selected avatar editing mode.
|
|
final ProfileAvatarMode mode;
|
|
|
|
/// Drives the shared preview transition into and out of editing.
|
|
final Animation<double> transition;
|
|
|
|
/// Called when the user selects a different avatar editing mode.
|
|
final ValueChanged<ProfileAvatarMode> onModeChanged;
|
|
|
|
/// Called whenever the unsaved avatar selection changes.
|
|
final ValueChanged<ProfileAvatarDraft?> onDraftChanged;
|
|
|
|
/// Supplies or clears the deferred animated-avatar preparation callback.
|
|
final ValueChanged<Future<ProfileAvatarDraft?> Function()?>
|
|
onAnimatedPrepareChanged;
|
|
|
|
/// Reports whether the inline still camera currently owns the image editor.
|
|
final ValueChanged<bool> onImageCameraActiveChanged;
|
|
|
|
/// Overrides the animated capture surface, primarily for tests.
|
|
final AnimatedAvatarCaptureBuilder? animatedCaptureBuilder;
|
|
|
|
/// Overrides the still-image capture surface, primarily for tests.
|
|
final ImageAvatarCaptureBuilder? imageCaptureBuilder;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
|
final currentEmojiAvatar = useMemoized(
|
|
() => parseEmojiAvatarDataUrl(currentAvatarUrl),
|
|
[currentAvatarUrl],
|
|
);
|
|
final selectedEmoji = useState(currentEmojiAvatar?.emoji ?? '😊');
|
|
final initialColor = useMemoized(
|
|
() =>
|
|
currentEmojiAvatar?.colorValue ??
|
|
emojiAvatarColors[Random().nextInt(18)],
|
|
[currentEmojiAvatar],
|
|
);
|
|
final selectedColor = useState(initialColor);
|
|
final emojiSection = useState(_EmojiEditorSection.emoji);
|
|
final emojiPreviewKey = useState(0);
|
|
final isPickingImage = useState(false);
|
|
final isCapturingImage = useState(false);
|
|
final cameraDisposal = useRef(CameraDisposalBarrier());
|
|
final imageSelectionGeneration = useRef(0);
|
|
final currentMode = useRef(mode)..value = mode;
|
|
final error = useState<String?>(null);
|
|
final dataset = ref.watch(emojiDatasetOrEmptyProvider);
|
|
final modeTransitionDirection = useRef(1.0);
|
|
final modeTransitionFrom = useRef(mode);
|
|
final retainedPreview = useRef<Widget?>(null);
|
|
final retainedPreviewTop = useRef(0.0);
|
|
final modeTransitionController = useAnimationController(
|
|
duration: _motionDuration,
|
|
initialValue: 1,
|
|
);
|
|
final modeTransitionValue = useAnimation(modeTransitionController);
|
|
final modeTransitionProgress = reduceMotion
|
|
? 1.0
|
|
: _modeTransitionCurve.transform(modeTransitionValue);
|
|
|
|
void selectMode(ProfileAvatarMode nextMode) {
|
|
if (nextMode == mode) return;
|
|
modeTransitionFrom.value = mode;
|
|
modeTransitionDirection.value = nextMode.index > mode.index ? 1 : -1;
|
|
unawaited(HapticFeedback.selectionClick());
|
|
onAnimatedPrepareChanged(null);
|
|
if (mode == ProfileAvatarMode.image) {
|
|
imageSelectionGeneration.value++;
|
|
isPickingImage.value = false;
|
|
isCapturingImage.value = false;
|
|
onImageCameraActiveChanged(false);
|
|
}
|
|
if (!reduceMotion) modeTransitionController.value = 0;
|
|
onModeChanged(nextMode);
|
|
if (nextMode == ProfileAvatarMode.emoji) {
|
|
onDraftChanged(
|
|
ProfileUrlAvatarDraft(
|
|
emojiAvatarDataUrl(selectedEmoji.value, selectedColor.value),
|
|
),
|
|
);
|
|
}
|
|
if (reduceMotion) {
|
|
modeTransitionController.value = 1;
|
|
} else {
|
|
unawaited(modeTransitionController.forward());
|
|
}
|
|
}
|
|
|
|
void updateEmojiPreview() {
|
|
emojiPreviewKey.value++;
|
|
onDraftChanged(
|
|
ProfileUrlAvatarDraft(
|
|
emojiAvatarDataUrl(selectedEmoji.value, selectedColor.value),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> selectGalleryImage() async {
|
|
if (isPickingImage.value) return;
|
|
final operation = ++imageSelectionGeneration.value;
|
|
bool isCurrentOperation() =>
|
|
context.mounted &&
|
|
currentMode.value == ProfileAvatarMode.image &&
|
|
imageSelectionGeneration.value == operation;
|
|
isPickingImage.value = true;
|
|
error.value = null;
|
|
try {
|
|
final service = ref.read(mediaUploadServiceProvider);
|
|
unawaited(HapticFeedback.lightImpact());
|
|
final picked = await service.pickGalleryImage();
|
|
if (picked == null || !isCurrentOperation()) return;
|
|
final preparedPhoto = await service.prepareImageBytes(picked);
|
|
if (!context.mounted || !isCurrentOperation()) return;
|
|
final cropped = await Navigator.of(context).push<Uint8List>(
|
|
MaterialPageRoute(
|
|
builder: (_) => ProfileAvatarCropPage(
|
|
imageBytes: Future<Uint8List?>.value(preparedPhoto),
|
|
),
|
|
),
|
|
);
|
|
if (cropped == null || !isCurrentOperation()) return;
|
|
onDraftChanged(ProfileImageAvatarDraft(cropped));
|
|
} catch (_) {
|
|
if (isCurrentOperation()) {
|
|
error.value = "We couldn't prepare that photo. Try again.";
|
|
}
|
|
} finally {
|
|
if (isCurrentOperation()) isPickingImage.value = false;
|
|
}
|
|
}
|
|
|
|
void closeImageCamera() {
|
|
isCapturingImage.value = false;
|
|
onImageCameraActiveChanged(false);
|
|
}
|
|
|
|
void acceptCameraImage(Uint8List bytes) {
|
|
onDraftChanged(ProfileImageAvatarDraft(bytes));
|
|
closeImageCamera();
|
|
}
|
|
|
|
final previewUrl = switch (draft) {
|
|
ProfileUrlAvatarDraft(:final url) => url,
|
|
_ => currentAvatarUrl,
|
|
};
|
|
final fixedPreviewContent = switch (mode) {
|
|
ProfileAvatarMode.image when draft is ProfileImageAvatarDraft =>
|
|
CircleAvatar(
|
|
key: const ValueKey('avatar-editor-fixed-preview'),
|
|
radius: _previewSize / 2,
|
|
backgroundImage: MemoryImage(
|
|
(draft as ProfileImageAvatarDraft).bytes,
|
|
),
|
|
),
|
|
ProfileAvatarMode.image => PlayingAvatarImage(
|
|
key: const ValueKey('avatar-editor-fixed-preview'),
|
|
imageUrl: previewUrl,
|
|
radius: _previewSize / 2,
|
|
backgroundColor: context.controlSurface,
|
|
fallback: Text(
|
|
fallbackInitial,
|
|
style: context.textTheme.displayLarge?.copyWith(
|
|
color: context.colors.onPrimaryContainer,
|
|
),
|
|
),
|
|
),
|
|
ProfileAvatarMode.emoji => _EmojiAvatarPreview(
|
|
key: const ValueKey('avatar-editor-fixed-preview'),
|
|
emoji: selectedEmoji.value,
|
|
color: Color(selectedColor.value),
|
|
animationKey: emojiPreviewKey.value,
|
|
reduceMotion:
|
|
reduceMotion ||
|
|
(modeTransitionFrom.value == ProfileAvatarMode.animated &&
|
|
modeTransitionProgress < 1),
|
|
),
|
|
ProfileAvatarMode.animated => null,
|
|
};
|
|
final fixedPreview = fixedPreviewContent == null
|
|
? null
|
|
: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
fixedPreviewContent,
|
|
if (isPickingImage.value)
|
|
Container(
|
|
key: const ValueKey('avatar-image-loading-overlay'),
|
|
width: _previewSize,
|
|
height: _previewSize,
|
|
decoration: BoxDecoration(
|
|
color: context.colors.surface.withValues(alpha: 0.72),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Center(
|
|
child: BuzzLoadingIndicator(
|
|
semanticLabel: 'Preparing photo',
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
if (mode != ProfileAvatarMode.animated && fixedPreview != null) {
|
|
retainedPreview.value = mode == ProfileAvatarMode.emoji
|
|
? _EmojiAvatarPreview(
|
|
emoji: selectedEmoji.value,
|
|
color: Color(selectedColor.value),
|
|
animationKey: emojiPreviewKey.value,
|
|
reduceMotion: true,
|
|
)
|
|
: fixedPreview;
|
|
}
|
|
|
|
final curvedEntrance = CurvedAnimation(
|
|
parent: transition,
|
|
curve: _entranceCurve,
|
|
// The controller runs from 1 → 0 on exit. An ease-in value curve makes
|
|
// that visible reverse movement ease out from expanded to collapsed.
|
|
reverseCurve: _exitCurve,
|
|
);
|
|
final appBarHeight = frostedAppBarHeight(context);
|
|
final topPadding = appBarHeight + Grid.xs;
|
|
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final viewportHeight = constraints.maxHeight;
|
|
final basePreviewTop = viewportHeight / 2 - _previewBlockSize / 2;
|
|
final maximumShift = max(
|
|
0.0,
|
|
basePreviewTop -
|
|
(topPadding + _modeControlHeight + _previewControlGap),
|
|
);
|
|
final requestedShift = mode != ProfileAvatarMode.emoji
|
|
? 0.0
|
|
: _emojiPickerPreviewShift;
|
|
final previewShift = min(requestedShift, maximumShift);
|
|
final previewTop = basePreviewTop - previewShift;
|
|
final returningToEmoji =
|
|
mode == ProfileAvatarMode.emoji &&
|
|
modeTransitionFrom.value == ProfileAvatarMode.animated;
|
|
final animatedModeHeight = max(
|
|
0.0,
|
|
viewportHeight - _editorControlsBottom - basePreviewTop,
|
|
);
|
|
final animatedPreviewSize = animatedModeHeight < 400 ? 180.0 : 228.0;
|
|
final animatedPreviewTop =
|
|
basePreviewTop + (animatedPreviewSize - _previewBlockSize) / 2;
|
|
final displayedPreviewTop = returningToEmoji
|
|
? animatedPreviewTop +
|
|
(previewTop - animatedPreviewTop) * modeTransitionProgress
|
|
: previewTop;
|
|
if (mode != ProfileAvatarMode.animated) {
|
|
retainedPreviewTop.value = previewTop;
|
|
}
|
|
final fixedContentTop =
|
|
previewTop + _previewBlockSize + _previewControlGap;
|
|
final imageCameraTop =
|
|
basePreviewTop +
|
|
_previewBlockSize / 2 -
|
|
imageAvatarCameraPreviewSize / 2;
|
|
final modeTop = mode == ProfileAvatarMode.animated
|
|
? basePreviewTop
|
|
: mode == ProfileAvatarMode.image && isCapturingImage.value
|
|
? imageCameraTop
|
|
: fixedContentTop;
|
|
final modeHeight = max(
|
|
0.0,
|
|
viewportHeight - _editorControlsBottom - modeTop,
|
|
);
|
|
final modeContent = switch (mode) {
|
|
ProfileAvatarMode.image when isCapturingImage.value => KeyedSubtree(
|
|
key: const ValueKey('image-camera-mode'),
|
|
child:
|
|
imageCaptureBuilder?.call(
|
|
height: modeHeight,
|
|
onAccepted: acceptCameraImage,
|
|
onClosed: closeImageCamera,
|
|
) ??
|
|
ImageAvatarCapture(
|
|
height: modeHeight,
|
|
initialPreview: fixedPreview,
|
|
onAccepted: acceptCameraImage,
|
|
onClosed: closeImageCamera,
|
|
disposalBarrier: cameraDisposal.value,
|
|
),
|
|
),
|
|
ProfileAvatarMode.image => _ImageMode(
|
|
key: const ValueKey(0),
|
|
height: modeHeight,
|
|
isPicking: isPickingImage.value,
|
|
onCamera: () {
|
|
error.value = null;
|
|
isCapturingImage.value = true;
|
|
onImageCameraActiveChanged(true);
|
|
},
|
|
onLibrary: () => unawaited(selectGalleryImage()),
|
|
),
|
|
ProfileAvatarMode.emoji => _EmojiMode(
|
|
key: const ValueKey(1),
|
|
height: modeHeight,
|
|
activeSection: emojiSection.value,
|
|
dataset: dataset,
|
|
selectedEmoji: selectedEmoji.value,
|
|
selectedColor: selectedColor.value,
|
|
transitionProgress: modeTransitionProgress,
|
|
transitionDirection: modeTransitionDirection.value,
|
|
onSectionChanged: (section) => emojiSection.value = section,
|
|
onEmojiSelected: (emoji) {
|
|
selectedEmoji.value = emoji;
|
|
updateEmojiPreview();
|
|
},
|
|
onColorSelected: (color) {
|
|
selectedColor.value = color;
|
|
updateEmojiPreview();
|
|
},
|
|
),
|
|
ProfileAvatarMode.animated => KeyedSubtree(
|
|
key: const ValueKey(2),
|
|
child:
|
|
animatedCaptureBuilder?.call(
|
|
height: modeHeight,
|
|
onPrepareChanged: onAnimatedPrepareChanged,
|
|
) ??
|
|
AnimatedAvatarCapture(
|
|
height: modeHeight,
|
|
onPrepareChanged: onAnimatedPrepareChanged,
|
|
disposalBarrier: cameraDisposal.value,
|
|
),
|
|
),
|
|
};
|
|
final collapsedPreviewOffset =
|
|
appBarHeight +
|
|
_settingsAvatarCenterBelowAppBar -
|
|
(previewTop + _previewBlockSize / 2);
|
|
final transitionedModeContent =
|
|
mode == ProfileAvatarMode.animated ||
|
|
mode == ProfileAvatarMode.emoji
|
|
? modeContent
|
|
: ClipRect(
|
|
child: Transform.translate(
|
|
key: const ValueKey('avatar-mode-transition-transform'),
|
|
offset: Offset(
|
|
modeTransitionDirection.value *
|
|
_modeTransitionDistance *
|
|
(1 - modeTransitionProgress),
|
|
0,
|
|
),
|
|
child: Opacity(
|
|
key: const ValueKey('avatar-mode-transition-opacity'),
|
|
opacity: modeTransitionProgress,
|
|
child: modeContent,
|
|
),
|
|
),
|
|
);
|
|
|
|
return Stack(
|
|
key: const ValueKey('avatar-editor-content'),
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Positioned(
|
|
left: Grid.gutter,
|
|
right: Grid.gutter,
|
|
top: topPadding,
|
|
child: FadeTransition(
|
|
opacity: curvedEntrance,
|
|
child: ScaleTransition(
|
|
scale: Tween(begin: 0.96, end: 1.0).animate(curvedEntrance),
|
|
child: Builder(
|
|
builder: (context) {
|
|
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
|
return IosNativeSegmentedControl(
|
|
key: const ValueKey('avatar-mode-control'),
|
|
items: const ['Image', 'Emoji', 'Animated'],
|
|
selectedIndex: mode.index,
|
|
onChanged: (index) {
|
|
if (index < 0 ||
|
|
index >= ProfileAvatarMode.values.length) {
|
|
return;
|
|
}
|
|
selectMode(ProfileAvatarMode.values[index]);
|
|
},
|
|
);
|
|
}
|
|
return _AvatarModeControl(
|
|
selected: mode,
|
|
reduceMotion: reduceMotion,
|
|
onSelected: selectMode,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (fixedPreview != null && !isCapturingImage.value)
|
|
AnimatedPositioned(
|
|
key: const ValueKey('avatar-preview-position'),
|
|
curve: Curves.easeOutCubic,
|
|
left: Grid.gutter,
|
|
right: Grid.gutter,
|
|
top: displayedPreviewTop,
|
|
height: _previewBlockSize,
|
|
duration: returningToEmoji
|
|
? Duration.zero
|
|
: reduceMotion
|
|
? Duration.zero
|
|
: const Duration(milliseconds: 150),
|
|
child: Center(
|
|
child: AnimatedBuilder(
|
|
animation: curvedEntrance,
|
|
child: fixedPreview,
|
|
builder: (context, child) {
|
|
final progress = curvedEntrance.value;
|
|
return Transform.translate(
|
|
key: const ValueKey('avatar-editor-entrance-transform'),
|
|
offset: Offset(
|
|
0,
|
|
collapsedPreviewOffset * (1 - progress),
|
|
),
|
|
child: Transform.scale(
|
|
scale:
|
|
128 / _previewSize +
|
|
(1 - 128 / _previewSize) * progress,
|
|
child: child,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
AnimatedPositioned(
|
|
duration: isCapturingImage.value || reduceMotion
|
|
? Duration.zero
|
|
: const Duration(milliseconds: 150),
|
|
curve: Curves.easeOutCubic,
|
|
left: Grid.gutter,
|
|
right: Grid.gutter,
|
|
top: modeTop,
|
|
height: modeHeight,
|
|
child: FadeTransition(
|
|
opacity: curvedEntrance,
|
|
child: transitionedModeContent,
|
|
),
|
|
),
|
|
if (mode == ProfileAvatarMode.animated &&
|
|
!reduceMotion &&
|
|
modeTransitionProgress < 1 &&
|
|
retainedPreview.value != null)
|
|
Positioned(
|
|
key: const ValueKey('avatar-mode-retained-preview'),
|
|
left: Grid.gutter,
|
|
right: Grid.gutter,
|
|
top:
|
|
retainedPreviewTop.value +
|
|
(basePreviewTop - retainedPreviewTop.value) *
|
|
modeTransitionProgress,
|
|
height: _previewBlockSize,
|
|
child: IgnorePointer(
|
|
child: Opacity(
|
|
opacity: 1 - modeTransitionProgress,
|
|
child: Center(child: retainedPreview.value),
|
|
),
|
|
),
|
|
),
|
|
if (error.value != null)
|
|
Positioned(
|
|
left: Grid.gutter,
|
|
right: Grid.gutter,
|
|
bottom: _editorControlsBottom + _editorRailHeight,
|
|
child: Semantics(
|
|
liveRegion: true,
|
|
child: Text(
|
|
error.value!,
|
|
textAlign: TextAlign.center,
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: context.colors.error,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AvatarModeControl extends StatelessWidget {
|
|
const _AvatarModeControl({
|
|
required this.selected,
|
|
required this.reduceMotion,
|
|
required this.onSelected,
|
|
});
|
|
|
|
final ProfileAvatarMode selected;
|
|
final bool reduceMotion;
|
|
final ValueChanged<ProfileAvatarMode> onSelected;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Material(
|
|
key: const ValueKey('avatar-mode-control'),
|
|
color: context.colors.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(Radii.full),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(Grid.quarter),
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final segmentWidth =
|
|
constraints.maxWidth / ProfileAvatarMode.values.length;
|
|
return Stack(
|
|
children: [
|
|
TweenAnimationBuilder<double>(
|
|
tween: Tween(end: selected.index.toDouble()),
|
|
duration: reduceMotion ? Duration.zero : _motionDuration,
|
|
curve: Curves.easeInOut,
|
|
builder: (context, position, child) => Transform.translate(
|
|
offset: Offset(segmentWidth * position, 0),
|
|
child: child,
|
|
),
|
|
child: SizedBox(
|
|
width: segmentWidth,
|
|
height: 36,
|
|
child: DecoratedBox(
|
|
key: const ValueKey('avatar-mode-indicator'),
|
|
decoration: BoxDecoration(
|
|
color: context.colors.surface,
|
|
borderRadius: BorderRadius.circular(Radii.full),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Row(
|
|
children: [
|
|
for (final mode in ProfileAvatarMode.values)
|
|
Expanded(
|
|
child: Semantics(
|
|
label: switch (mode) {
|
|
ProfileAvatarMode.image => 'Image',
|
|
ProfileAvatarMode.emoji => 'Emoji',
|
|
ProfileAvatarMode.animated => 'Animated',
|
|
},
|
|
button: true,
|
|
selected: mode == selected,
|
|
onTap: () => onSelected(mode),
|
|
child: ExcludeSemantics(
|
|
child: InkWell(
|
|
key: ValueKey('avatar-mode-${mode.name}'),
|
|
borderRadius: BorderRadius.circular(Radii.full),
|
|
onTap: () => onSelected(mode),
|
|
child: SizedBox(
|
|
height: 36,
|
|
child: Center(
|
|
child: Text(
|
|
switch (mode) {
|
|
ProfileAvatarMode.image => 'Image',
|
|
ProfileAvatarMode.emoji => 'Emoji',
|
|
ProfileAvatarMode.animated => 'Animated',
|
|
},
|
|
style: context.textTheme.labelLarge?.copyWith(
|
|
fontWeight: mode == selected
|
|
? FontWeight.w600
|
|
: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _ImageMode extends StatelessWidget {
|
|
const _ImageMode({
|
|
super.key,
|
|
required this.height,
|
|
required this.isPicking,
|
|
required this.onCamera,
|
|
required this.onLibrary,
|
|
});
|
|
|
|
final double height;
|
|
final bool isPicking;
|
|
final VoidCallback onCamera;
|
|
final VoidCallback onLibrary;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => SizedBox(
|
|
height: height,
|
|
child: Column(
|
|
children: [
|
|
const Spacer(),
|
|
Row(
|
|
children: [
|
|
const Spacer(),
|
|
Expanded(
|
|
child: _ImageSourceOption(
|
|
key: const ValueKey('image-source-camera'),
|
|
icon: BuzzIcons.camera,
|
|
iosIcon: IosGlassNavigationIcon.camera,
|
|
label: 'Camera',
|
|
onTap: isPicking ? null : onCamera,
|
|
labelMaxWidth: 96,
|
|
),
|
|
),
|
|
const SizedBox(width: Grid.half),
|
|
Expanded(
|
|
child: _ImageSourceOption(
|
|
key: const ValueKey('image-source-library'),
|
|
icon: BuzzIcons.images,
|
|
iosIcon: IosGlassNavigationIcon.photoLibrary,
|
|
label: 'Photo Library',
|
|
onTap: isPicking ? null : onLibrary,
|
|
labelMaxWidth: 104,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class _ImageSourceOption extends StatelessWidget {
|
|
const _ImageSourceOption({
|
|
super.key,
|
|
required this.icon,
|
|
required this.iosIcon,
|
|
required this.label,
|
|
required this.onTap,
|
|
required this.labelMaxWidth,
|
|
});
|
|
|
|
final IconData icon;
|
|
final IosGlassNavigationIcon iosIcon;
|
|
final String label;
|
|
final VoidCallback? onTap;
|
|
final double labelMaxWidth;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => AvatarEditorOptionButton(
|
|
icon: icon,
|
|
iosIcon: iosIcon,
|
|
label: label,
|
|
selected: false,
|
|
onTap: onTap,
|
|
labelMaxWidth: labelMaxWidth,
|
|
);
|
|
}
|