Files
buzz/mobile/lib/features/profile/set_status_sheet.dart
b30f1f6129 Polish mobile profiles, DMs, and sheets (#5401)
## Summary
- add poster-first, tap-to-toggle animated avatars on profile surfaces
while preserving transparent/static behavior elsewhere
- align mobile DM headers, membership actions, and invisible agent
recipient addressing with established desktop semantics
- polish titled sheets and status editing, preserve native iOS sheet
corners, and batch relay reads to improve review-build responsiveness

## Snapshots

<table>
  <tr>
    <th>Profile avatar</th>
    <th>Agent DM header and composer</th>
  </tr>
  <tr>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--profile-avatar.png"
width="360" alt="Mobile profile settings with animated avatar
surface"></td>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--agent-dm.png"
width="360" alt="Agent direct message with masked presence and normal
composer"></td>
  </tr>
  <tr>
    <th>Members sheet</th>
    <th>Status editor</th>
  </tr>
  <tr>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--members-sheet.png"
width="360" alt="Members bottom sheet with centered title and padded
content"></td>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--status-sheet.png"
width="360" alt="Status editor bottom sheet with duration and quick
statuses"></td>
  </tr>
  <tr>
    <th colspan="2">Switch Community</th>
  </tr>
  <tr>
<td colspan="2" align="center"><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--switch-community.png"
width="720" alt="Switch Community bottom sheet with centered title and
aligned Edit action"></td>
  </tr>
</table>

## Validation
- `just mobile-check`
- `just mobile-test` (1,283 tests)
- installed and reviewed isolated debug builds on iPhone and Pixel

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-13 20:16:04 -07:00

490 lines
16 KiB
Dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/app_list.dart';
import '../../shared/widgets/app_list_card.dart';
import '../../shared/widgets/buzz_titled_sheet_layout.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../channels/emoji_picker.dart';
import 'user_status.dart';
import 'user_status_provider.dart';
const _emojiWellSize = 48.0;
const _emojiGlyphSize = 28.0;
const _statusPresets = [
(text: 'In a meeting', emoji: '\u{1F5E3}\u{FE0F}'),
(text: 'Commuting', emoji: '\u{1F68C}'),
(text: 'Out sick', emoji: '\u{1F912}'),
(text: 'Vacationing', emoji: '\u{1F3D6}\u{FE0F}'),
(text: 'Working remotely', emoji: '\u{1F3E0}'),
];
enum _StatusDuration {
oneHour('1 hour', Duration(hours: 1)),
eightHours('8 hours', Duration(hours: 8)),
oneDay('1 day', Duration(days: 1)),
oneWeek('1 week', Duration(days: 7)),
custom('Custom', null);
const _StatusDuration(this.label, this.duration);
final String label;
final Duration? duration;
}
void showSetStatusSheet(BuildContext context, {UserStatus? currentStatus}) {
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showCloseButton: false,
showDragHandle: false,
builder: (_) => _SetStatusSheet(currentStatus: currentStatus),
);
}
class _SetStatusSheet extends HookConsumerWidget {
const _SetStatusSheet({this.currentStatus});
final UserStatus? currentStatus;
@override
Widget build(BuildContext context, WidgetRef ref) {
final textController = useTextEditingController(
text: currentStatus?.text ?? '',
);
final emoji = useState(currentStatus?.emoji ?? '');
final text = useState(currentStatus?.text ?? '');
final currentExpiration = currentStatus?.expirationDateTime;
final duration = useState(
currentExpiration == null
? _StatusDuration.oneDay
: _StatusDuration.custom,
);
final customUntil = useState(
currentExpiration ?? DateTime.now().add(_StatusDuration.oneDay.duration!),
);
final isSaving = useState(false);
useEffect(() {
void listener() => text.value = textController.text;
textController.addListener(listener);
return () => textController.removeListener(listener);
}, [textController]);
final hasContent = text.value.trim().isNotEmpty || emoji.value.isNotEmpty;
final hasExistingStatus = currentStatus != null && !currentStatus!.isEmpty;
DateTime expiresAt() => switch (duration.value) {
_StatusDuration.custom => customUntil.value,
final preset => DateTime.now().add(preset.duration!),
};
Future<void> handleSave() async {
if (isSaving.value || !hasContent) return;
isSaving.value = true;
try {
await ref
.read(userStatusProvider.notifier)
.setStatus(text.value, emoji.value, expiresAt: expiresAt());
if (context.mounted) Navigator.of(context).pop();
} finally {
isSaving.value = false;
}
}
Future<void> handleClear() async {
if (isSaving.value) return;
isSaving.value = true;
try {
await ref.read(userStatusProvider.notifier).clearStatus();
if (context.mounted) Navigator.of(context).pop();
} finally {
isSaving.value = false;
}
}
Future<void> pickCustomUntil() async {
final picked = await _showNativeDateTimePicker(
context,
initial: customUntil.value,
);
if (picked == null) return;
duration.value = _StatusDuration.custom;
customUntil.value = picked;
}
Future<void> chooseDuration() async {
FocusScope.of(context).unfocus();
final selected = await _showStatusDurationSheet(
context,
selected: duration.value,
);
if (selected == null || !context.mounted) return;
if (selected == _StatusDuration.custom) {
await pickCustomUntil();
} else {
duration.value = selected;
}
}
final headerAction = SizedBox.square(
dimension: 44,
child: IconButton(
key: const ValueKey('save-status-button'),
tooltip: 'Save status',
onPressed: hasContent && !isSaving.value ? handleSave : null,
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
backgroundColor: context.colors.surfaceContainerHighest,
foregroundColor: context.colors.onSurface,
disabledForegroundColor: context.colors.onSurfaceVariant.withValues(
alpha: 0.38,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.dialog),
),
),
icon: const Icon(LucideIcons.check, size: 22),
),
);
return SafeArea(
top: false,
child: BuzzTitledSheetLayout(
title: 'Set a status',
showDragHandle: true,
leading: headerAction,
child: SingleChildScrollView(
padding: EdgeInsets.only(
// Keep the sheet at its resting height while the keyboard
// overlays its lower portion. Adding viewInsets here makes the
// modal itself grow upward by the full keyboard height.
bottom: Grid.gutter + MediaQuery.viewPaddingOf(context).bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: _StatusInput(
controller: textController,
emoji: emoji.value,
enabled: !isSaving.value,
onChooseEmoji: () => showEmojiPicker(
context: context,
onSelect: (value) => emoji.value = value,
),
onRemoveEmoji: () => emoji.value = '',
onSubmitted: handleSave,
),
),
AppListCard(
label: 'Duration',
children: [
AppListRow(
icon: LucideIcons.clock3,
title: 'Duration',
value: duration.value.label,
trailing: const Icon(LucideIcons.chevronDown, size: 18),
onTap: chooseDuration,
),
if (duration.value == _StatusDuration.custom)
AppListRow(
icon: LucideIcons.calendarClock,
title: 'Until',
value: _formatUntil(customUntil.value),
onTap: pickCustomUntil,
),
],
),
AppListCard(
label: 'Quick statuses',
children: [
for (final preset in _statusPresets)
AppListRowRaw(
leading: SizedBox(
width: 22,
child: Center(
child: Text(
preset.emoji,
style: const TextStyle(fontSize: 20),
),
),
),
title: Text(
preset.text,
style: context.textTheme.bodyLarge,
),
onTap: () {
textController.text = preset.text;
emoji.value = preset.emoji;
},
),
],
),
if (hasExistingStatus)
AppListCard(
children: [
AppListRow(
icon: LucideIcons.trash2,
title: 'Clear status',
titleColor: context.colors.error,
onTap: isSaving.value ? null : handleClear,
),
],
),
],
),
),
),
);
}
}
class _StatusInput extends ConsumerWidget {
const _StatusInput({
required this.controller,
required this.emoji,
required this.enabled,
required this.onChooseEmoji,
required this.onRemoveEmoji,
required this.onSubmitted,
});
final TextEditingController controller;
final String emoji;
final bool enabled;
final VoidCallback onChooseEmoji;
final VoidCallback onRemoveEmoji;
final VoidCallback onSubmitted;
@override
Widget build(BuildContext context, WidgetRef ref) {
return DecoratedBox(
key: const ValueKey('status-input-outline'),
decoration: BoxDecoration(
border: Border.all(
color: context.colors.outlineVariant.withValues(alpha: 0.8),
),
borderRadius: BorderRadius.circular(Radii.card),
),
child: Row(
children: [
SizedBox.square(
dimension: _emojiWellSize,
child: Stack(
clipBehavior: Clip.none,
children: [
Semantics(
button: true,
label: 'Choose a status emoji',
child: InkWell(
borderRadius: BorderRadius.circular(Radii.card),
onTap: enabled ? onChooseEmoji : null,
child: Center(child: _StatusEmojiPreview(emoji: emoji)),
),
),
if (emoji.isNotEmpty)
Positioned(
top: -Grid.half,
right: -Grid.half,
child: SizedBox.square(
dimension: Grid.sm,
child: IconButton(
onPressed: enabled ? onRemoveEmoji : null,
tooltip: 'Remove status emoji',
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(
backgroundColor: context.colors.surface,
minimumSize: const Size.square(Grid.sm),
maximumSize: const Size.square(Grid.sm),
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: Icon(
LucideIcons.x,
size: 14,
color: context.colors.onSurface,
),
),
),
),
],
),
),
Expanded(
child: TextField(
controller: controller,
enabled: enabled,
autofocus: false,
style: context.textTheme.bodyLarge,
decoration: InputDecoration(
hintText: 'What\u2019s your status?',
hintStyle: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.only(
left: Grid.xxs,
right: Grid.xs,
),
),
textInputAction: TextInputAction.done,
onSubmitted: (_) => onSubmitted(),
),
),
],
),
);
}
}
class _StatusEmojiPreview extends ConsumerWidget {
const _StatusEmojiPreview({required this.emoji});
final String emoji;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (emoji.isEmpty) {
return Icon(
LucideIcons.smilePlus,
size: _emojiGlyphSize,
color: context.colors.onSurfaceVariant,
);
}
final palette = ref.watch(customEmojiListProvider);
final shortcode = normalizeShortcode(emoji);
if (shortcode != null) {
for (final entry in palette) {
if (entry.shortcode == shortcode) {
return CustomEmojiImage(
shortcode: shortcode,
url: entry.url,
size: _emojiGlyphSize,
);
}
}
}
return Text(emoji, style: const TextStyle(fontSize: _emojiGlyphSize));
}
}
Future<_StatusDuration?> _showStatusDurationSheet(
BuildContext context, {
required _StatusDuration selected,
}) {
return showBuzzModalBottomSheet<_StatusDuration>(
context: context,
title: 'Duration',
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => SingleChildScrollView(
child: SafeArea(
top: false,
child: AppListCard(
children: [
for (final option in _StatusDuration.values)
AppListRow(
title: option.label,
trailing: option == selected
? const Icon(LucideIcons.check, size: 18)
: null,
onTap: () => Navigator.of(sheetContext).pop(option),
),
],
),
),
),
);
}
Future<DateTime?> _showNativeDateTimePicker(
BuildContext context, {
required DateTime initial,
}) async {
final now = DateTime.now();
final minimum = now.add(const Duration(minutes: 5));
final safeInitial = initial.isAfter(minimum) ? initial : minimum;
if (defaultTargetPlatform == TargetPlatform.iOS) {
var selected = safeInitial;
return showCupertinoModalPopup<DateTime>(
context: context,
builder: (pickerContext) => Material(
color: context.colors.surface,
child: SafeArea(
top: false,
child: SizedBox(
height: 300,
child: Column(
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => Navigator.of(pickerContext).pop(selected),
child: const Text('Done'),
),
),
Expanded(
child: CupertinoDatePicker(
mode: CupertinoDatePickerMode.dateAndTime,
minimumDate: minimum,
initialDateTime: safeInitial,
use24hFormat: MediaQuery.alwaysUse24HourFormatOf(context),
onDateTimeChanged: (value) => selected = value,
),
),
],
),
),
),
),
);
}
final lastDate = now.add(const Duration(days: 365));
final androidInitial = safeInitial.isAfter(lastDate) ? lastDate : safeInitial;
final date = await showDatePicker(
context: context,
initialDate: androidInitial,
firstDate: now,
lastDate: lastDate,
);
if (date == null || !context.mounted) return null;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(androidInitial),
);
if (time == null) return null;
final selected = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
return selected.isBefore(minimum) ? minimum : selected;
}
String _formatUntil(DateTime value) =>
DateFormat('EEE, MMM d \u00B7 h:mm a').format(value);