Files
buzz/mobile/lib/features/profile/set_status_sheet.dart
klopez4212 cb69ad9e4f Adapt iPhone Duo layouts, rail navigation, and native sheets (#8568)
Duo keeps conversations and Settings details open across closed, book
and flat postures. Book mode splits at the fold; flattening restores the
sidebar. Native rail controls, full-width navigation backdrops and
sheet-local safe areas keep content aligned. Ordinary iPhones remain
single-pane in portrait and landscape; tablet and Duo split layouts
remain enabled.

Includes the recovered preview's matching pane widths, Buzz empty-pane
mark, stable Settings chevrons, Bestie pane routing and Duo-only upward
New menu attachment. Current main's Activity redesign, Search content
fade and unread-navigation button are retained. Menu selection checks
sit beside their labels.

Shared mobile changes remain in scope: the native iOS status editor,
Appearance preview layout, avatar crop preservation, onboarding footer
layout, profile/invite sheet geometry and nested conversation
navigation. This PR is not exclusively Duo-gated. Nested navigator
observers preserve recording cancellation and navigation callbacks; crop
resizing preserves the selected image region. iOS 27.1 APIs remain
compile-time and runtime gated.

Validation on the reconciled source: Flutter analysis, Activity/Home
journeys, the full mobile test suite, file-size checks and Swift package
tests. The iPhone single-pane fix was also exercised on an iPhone 18 Pro
simulator before reconciliation. Fresh hosted CI and physical-device
confirmation of the reconciled build are separate and remain pending.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@squareup.com>
2026-11-12 19:00:58 +00:00

619 lines
20 KiB
Dart

import 'dart:async';
import 'package:duo_kit/duo_kit.dart';
import '../../shared/widgets/duo_toolbar.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.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:buzz/shared/theme/buzz_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 '../../shared/emoji/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;
}
bool _nativeStatusEditorOpen = false;
Future<void> showSetStatusSheet(
BuildContext context, {
UserStatus? currentStatus,
}) async {
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) {
if (_nativeStatusEditorOpen) return;
_nativeStatusEditorOpen = true;
try {
await _showNativeStatusEditor(context, currentStatus);
return;
} on MissingPluginException {
// Keep the portable editor available on hosts without the iOS plugin.
if (!context.mounted) return;
} finally {
_nativeStatusEditorOpen = false;
}
}
if (!context.mounted) return;
await showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showCloseButton: false,
showRailCloseButton: true,
showDragHandle: false,
builder: (_) => _SetStatusSheet(currentStatus: currentStatus),
);
}
Future<void> _showNativeStatusEditor(
BuildContext context,
UserStatus? currentStatus,
) async {
const channel = MethodChannel('buzz/profile_text_editor');
final container = ProviderScope.containerOf(context, listen: false);
final notifier = container.read(userStatusProvider.notifier);
final sheetTheme = utilitySurfaceThemeData(Theme.of(context));
final currentExpiration = currentStatus?.expirationDateTime;
var draft = <String, dynamic>{
'text': currentStatus?.text ?? '',
'emoji': currentStatus?.emoji ?? '',
'duration': currentExpiration == null ? 86400 : 0,
'expiresAt': currentExpiration == null
? null
: currentExpiration.millisecondsSinceEpoch / 1000,
'hasExistingStatus': currentStatus != null && !currentStatus.isEmpty,
'brightness': sheetTheme.brightness.name,
'pageBackgroundArgb': sheetTheme.colorScheme.surface.toARGB32(),
'containerBackgroundArgb': sheetTheme.colorScheme.surfaceContainerHighest
.toARGB32(),
};
channel.setMethodCallHandler((call) async {
if (call.method != 'chooseStatusEmoji' || !context.mounted) return null;
// Resolve the palette before opening the picker: its Flutter loading
// surface would otherwise sit underneath this native presentation.
await container.read(customEmojiPaletteProvider.future);
if (!context.mounted) return null;
final selection = Completer<String?>();
showEmojiPicker(
context: context,
onSelect: (emoji) {
if (!selection.isCompleted) selection.complete(emoji);
},
onDismiss: () {
if (!selection.isCompleted) selection.complete(null);
},
);
return selection.future;
});
try {
while (context.mounted) {
final value = await channel.invokeMapMethod<String, dynamic>(
'presentStatus',
draft,
);
if (value == null) return;
final expiration = _nativeStatusExpiration(value['expiresAt']);
if (value['clear'] != true && expiration == null) {
// Preserve the edit, but replace the invalid date with the default preset
// so pressing Set again can recover without resubmitting the bad value.
draft = {
...draft,
...value,
'duration': 86400,
'expiresAt': null,
'saveError': true,
};
continue;
}
try {
if (value['clear'] == true) {
await notifier.clearStatus();
} else {
await notifier.setStatus(
value['text'] as String,
value['emoji'] as String,
expiresAt: expiration,
);
}
return;
} catch (_) {
// Reopen with the exact submitted draft, just like the native text forms.
// The error belongs to this editor, so it remains visible above the sheet.
draft = {...draft, ...value, 'saveError': true};
}
}
} finally {
channel.setMethodCallHandler(null);
}
}
DateTime? _nativeStatusExpiration(Object? value) {
// Validate before multiplying or rounding untrusted method-channel input.
if (value is! num ||
!value.isFinite ||
value < -8640000000000 ||
value > 8640000000000) {
return null;
}
return DateTime.fromMillisecondsSinceEpoch((value * 1000).round());
}
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(BuzzIcons.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: BuzzIcons.clock3,
title: 'Duration',
value: duration.value.label,
trailing: const Icon(BuzzIcons.chevronDown, size: 18),
onTap: chooseDuration,
),
if (duration.value == _StatusDuration.custom)
AppListRow(
icon: BuzzIcons.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: BuzzIcons.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.container),
),
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.container),
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(
BuzzIcons.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(
BuzzIcons.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(BuzzIcons.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;
Widget picker(BuildContext pickerContext) => Material(
color: context.colors.surface,
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.only(bottom: Grid.half),
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 strip =
DuoToolbarScope.maybeOf(context)?.strip ??
FoldGeometry.of(context).strip;
if (strip != null) {
return showBuzzModalBottomSheet<DateTime>(
context: context,
title: 'Custom expiration',
builder: picker,
);
}
return showCupertinoModalPopup<DateTime>(context: context, builder: picker);
}
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);