mirror of
https://github.com/block/buzz.git
synced 2026-11-15 13:51:12 +00:00
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>
374 lines
14 KiB
Dart
374 lines
14 KiB
Dart
import 'package:buzz/features/settings/theme_picker_page.dart';
|
|
import 'package:buzz/shared/theme/theme.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:buzz/shared/widgets/ios_navigation_bar.dart';
|
|
import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../../helpers/widget_helpers.dart';
|
|
|
|
Future<SharedPreferences> _prefs(Map<String, Object> initial) async {
|
|
SharedPreferences.setMockInitialValues(initial);
|
|
return SharedPreferences.getInstance();
|
|
}
|
|
|
|
Future<SharedPreferences> _pumpPicker(
|
|
WidgetTester tester, {
|
|
Map<String, Object> prefs = const {},
|
|
bool reduceMotion = false,
|
|
CommunityThemeNotifier Function()? themeNotifier,
|
|
}) async {
|
|
final instance = await _prefs(prefs);
|
|
await tester.pumpWidget(
|
|
WidgetHelpers.testable(
|
|
child: MediaQuery(
|
|
data: MediaQueryData(disableAnimations: reduceMotion),
|
|
child: const ThemePickerPage(),
|
|
),
|
|
overrides: [
|
|
savedPrefsProvider.overrideWithValue(instance),
|
|
if (themeNotifier != null)
|
|
communityThemeProvider.overrideWith(themeNotifier),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
return instance;
|
|
}
|
|
|
|
void main() {
|
|
testWidgets(
|
|
'Appearance preserves its draft inside Duo content bounds across folds',
|
|
(tester) async {
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
tester.view.devicePixelRatio = 1;
|
|
addTearDown(tester.view.reset);
|
|
final instance = await _prefs({});
|
|
final notifier = _RecordingCommunityThemeNotifier();
|
|
try {
|
|
tester.view.physicalSize = const Size(466, 678);
|
|
tester.view.viewPadding = const FakeViewPadding(right: 76, bottom: 21);
|
|
tester.view.padding = const FakeViewPadding(right: 76, bottom: 21);
|
|
await tester.pumpWidget(
|
|
WidgetHelpers.testable(
|
|
child: const ThemePickerPage(),
|
|
overrides: [
|
|
savedPrefsProvider.overrideWithValue(instance),
|
|
communityThemeProvider.overrideWith(() => notifier),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
// The OS button is the only mocked boundary.
|
|
IosNavigationAction railAction(String label) => tester
|
|
.widget<IosNavigationBar>(
|
|
find.descendant(
|
|
of: find.byKey(ValueKey('duo-toolbar-$label')),
|
|
matching: find.byType(IosNavigationBar),
|
|
),
|
|
)
|
|
.leading!;
|
|
IosGlassNavigationButton modeButton() =>
|
|
tester.widget<IosGlassNavigationButton>(
|
|
find.byKey(
|
|
const ValueKey('theme-preview-appearance-action-button'),
|
|
),
|
|
);
|
|
modeButton().onPressed!();
|
|
await tester.pumpAndSettle();
|
|
tester
|
|
.widget<IosGlassNavigationButton>(
|
|
find.descendant(
|
|
of: find.byKey(
|
|
const ValueKey('theme-preview-accent-action-button'),
|
|
),
|
|
matching: find.byType(IosGlassNavigationButton),
|
|
),
|
|
)
|
|
.onPressed!();
|
|
await tester.pumpAndSettle();
|
|
final jade = chatColorChoices.indexWhere(
|
|
(color) => color.name == 'Jade',
|
|
);
|
|
final option = find.byKey(ValueKey('accent-option-$jade'));
|
|
await tester.ensureVisible(option);
|
|
await tester.tap(option);
|
|
await tester.pumpAndSettle();
|
|
Navigator.of(tester.element(option)).pop();
|
|
await tester.pumpAndSettle();
|
|
for (final (size, left) in [
|
|
(const Size(466, 678), false),
|
|
(const Size(951, 669), false),
|
|
(const Size(669, 951), false),
|
|
(const Size(678, 466), true),
|
|
]) {
|
|
tester.view.physicalSize = size;
|
|
final padding = FakeViewPadding(
|
|
left: left ? 76 : 0,
|
|
right: left ? 0 : 76,
|
|
bottom: 21,
|
|
);
|
|
tester.view.viewPadding = padding;
|
|
tester.view.padding = padding;
|
|
await tester.pumpAndSettle();
|
|
expect(tester.takeException(), isNull);
|
|
expect(modeButton().icon, IosGlassNavigationIcon.sun);
|
|
expect(notifier.preferenceWrites, 0);
|
|
final preview = tester.getRect(
|
|
find.byKey(const ValueKey('theme-preview-page-$buzzThemeName')),
|
|
);
|
|
expect(preview.left, greaterThanOrEqualTo(left ? 76 : 0));
|
|
expect(
|
|
preview.right,
|
|
lessThanOrEqualTo(size.width - (left ? 0 : 76)),
|
|
);
|
|
final images = tester.getCenter(
|
|
find.byKey(
|
|
const ValueKey('theme-device-pair-preview-$buzzThemeName'),
|
|
),
|
|
);
|
|
expect(images.dy, closeTo(size.height / 2, 1));
|
|
expect(images.dx, closeTo((size.width + (left ? 76 : -76)) / 2, 1));
|
|
final wide = size.width - 76 >= 640 && size.width > size.height;
|
|
final controls = tester.getRect(
|
|
find.byKey(
|
|
ValueKey(
|
|
wide ? 'theme-preview-bottom-row' : 'theme-preview-rail-bottom',
|
|
),
|
|
),
|
|
);
|
|
final top = tester.getRect(
|
|
find.byKey(const ValueKey('duo-toolbar-Set')),
|
|
);
|
|
expect(controls.top, greaterThan(top.bottom));
|
|
expect(controls.bottom, lessThanOrEqualTo(size.height));
|
|
if (wide) {
|
|
expect(controls.bottom, size.height - 21);
|
|
final colorButton = tester.getCenter(
|
|
find.byKey(const ValueKey('theme-preview-accent-action-button')),
|
|
);
|
|
final appearanceButton = tester.getCenter(
|
|
find.byKey(
|
|
const ValueKey('theme-preview-appearance-action-button'),
|
|
),
|
|
);
|
|
expect(colorButton.dy, appearanceButton.dy);
|
|
expect(controls.height, 56);
|
|
tester
|
|
.widget<IosGlassNavigationButton>(
|
|
find.descendant(
|
|
of: find.byKey(
|
|
const ValueKey('theme-preview-accent-action-button'),
|
|
),
|
|
matching: find.byType(IosGlassNavigationButton),
|
|
),
|
|
)
|
|
.onPressed!();
|
|
await tester.pumpAndSettle();
|
|
final grid = find.byKey(const ValueKey('accent-selection-grid'));
|
|
expect(tester.getSize(grid).width, lessThanOrEqualTo(320));
|
|
Navigator.of(tester.element(grid)).pop();
|
|
await tester.pumpAndSettle();
|
|
} else {
|
|
expect(controls.center.dx, closeTo(top.center.dx, 1));
|
|
}
|
|
final swatch = tester.widget<IosGlassNavigationButton>(
|
|
find.descendant(
|
|
of: find.byKey(
|
|
const ValueKey('theme-preview-accent-action-button'),
|
|
),
|
|
matching: find.byType(IosGlassNavigationButton),
|
|
),
|
|
);
|
|
expect(swatch.icon, IosGlassNavigationIcon.colorSwatch);
|
|
expect(
|
|
swatch.swatchColor,
|
|
chatColorForScheme(lightColorScheme, jade),
|
|
);
|
|
expect(
|
|
find.byKey(const ValueKey('theme-preview-name-$buzzThemeName')),
|
|
findsNothing,
|
|
);
|
|
}
|
|
railAction('Set').onPressed!();
|
|
await tester.pumpAndSettle();
|
|
expect(notifier.preferenceWrites, 1);
|
|
expect(notifier.lastPreference?.mode, ThemeMode.light);
|
|
expect(
|
|
notifier.lastPreference?.accent,
|
|
chatColorChoices[jade].wireValue,
|
|
);
|
|
} finally {
|
|
debugDefaultTargetPlatformOverride = null;
|
|
}
|
|
},
|
|
);
|
|
|
|
testWidgets('one Home and Chat pair replaces the theme carousel', (
|
|
tester,
|
|
) async {
|
|
await _pumpPicker(tester, prefs: {'buzz_color_scheme': 'dracula'});
|
|
expect(find.byType(PageView), findsNothing);
|
|
expect(find.bySemanticsLabel('Home preview'), findsOneWidget);
|
|
expect(find.bySemanticsLabel('Chat preview'), findsOneWidget);
|
|
expect(
|
|
find.byKey(const ValueKey('theme-preview-accent-action-button')),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('Dracula'), findsNothing);
|
|
});
|
|
|
|
testWidgets('mode and chat color remain drafts until Set', (tester) async {
|
|
final notifier = _RecordingCommunityThemeNotifier();
|
|
await _pumpPicker(tester, themeNotifier: () => notifier);
|
|
// System -> Light -> Dark.
|
|
for (var i = 0; i < 2; i++) {
|
|
await tester.tap(
|
|
find.byKey(const ValueKey('theme-preview-appearance-action-button')),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
expect(find.text('Dark'), findsOneWidget);
|
|
await tester.tap(
|
|
find.byKey(const ValueKey('theme-preview-accent-action-button')),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Chat color'), findsOneWidget);
|
|
final green = chatColorChoices.indexWhere((color) => color.name == 'Jade');
|
|
await tester.ensureVisible(find.byKey(ValueKey('accent-option-$green')));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.byKey(ValueKey('accent-option-$green')));
|
|
await tester.pumpAndSettle();
|
|
expect(notifier.preferenceWrites, 0);
|
|
Navigator.of(
|
|
tester.element(find.byKey(ValueKey('accent-option-$green'))),
|
|
).pop();
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.byKey(const ValueKey('theme-preview-set')));
|
|
await tester.pumpAndSettle();
|
|
expect(notifier.preferenceWrites, 1);
|
|
expect(notifier.lastPreference?.mode, ThemeMode.dark);
|
|
expect(notifier.lastPreference?.theme, buzzDarkThemeName);
|
|
expect(notifier.lastPreference?.accent, chatColorChoices[green].wireValue);
|
|
});
|
|
|
|
for (final brightness in Brightness.values) {
|
|
testWidgets('${brightness.name} chat preview follows draft colors only', (
|
|
tester,
|
|
) async {
|
|
final notifier = _RecordingCommunityThemeNotifier();
|
|
await _pumpPicker(tester, themeNotifier: () => notifier);
|
|
// System starts with the test platform's light appearance.
|
|
if (brightness == Brightness.dark) {
|
|
for (var i = 0; i < 2; i++) {
|
|
await tester.tap(
|
|
find.byKey(
|
|
const ValueKey('theme-preview-appearance-action-button'),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
}
|
|
Color bubbleColor() =>
|
|
(tester
|
|
.widget<DecoratedBox>(
|
|
find.byKey(
|
|
const ValueKey('theme-chat-outgoing-bubble'),
|
|
),
|
|
)
|
|
.decoration
|
|
as BoxDecoration)
|
|
.color!;
|
|
Color foregroundColor() =>
|
|
(tester
|
|
.widget<DecoratedBox>(
|
|
find.byKey(
|
|
const ValueKey('theme-chat-outgoing-line-206.0'),
|
|
),
|
|
)
|
|
.decoration
|
|
as BoxDecoration)
|
|
.color!;
|
|
Color surfaceColor() => tester
|
|
.widget<ColoredBox>(
|
|
find.byKey(const ValueKey('theme-chat-preview-surface')),
|
|
)
|
|
.color;
|
|
final surface = surfaceColor();
|
|
final fills = <Color>[];
|
|
for (final name in ['Cobalt', 'Jade', 'Chartreuse', 'Gray']) {
|
|
await tester.tap(
|
|
find.byKey(const ValueKey('theme-preview-accent-action-button')),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
final index = chatColorChoices.indexWhere(
|
|
(color) => color.name == name,
|
|
);
|
|
final option = find.byKey(ValueKey('accent-option-$index'));
|
|
await tester.ensureVisible(option);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(option);
|
|
await tester.pumpAndSettle();
|
|
Navigator.of(tester.element(option)).pop();
|
|
await tester.pumpAndSettle();
|
|
final theme = mobileAppearanceTheme(
|
|
brightness,
|
|
chatColorChoices[index].wireValue,
|
|
);
|
|
final chat = theme.extension<ChatColors>()!;
|
|
expect(bubbleColor(), chat.outgoing);
|
|
expect(foregroundColor(), chat.foreground);
|
|
expect(surfaceColor(), surface);
|
|
expect(notifier.preferenceWrites, 0);
|
|
fills.add(bubbleColor());
|
|
}
|
|
expect(fills.toSet(), hasLength(4));
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
}
|
|
|
|
testWidgets('Close discards mode changes', (tester) async {
|
|
final notifier = _RecordingCommunityThemeNotifier();
|
|
await _pumpPicker(tester, themeNotifier: () => notifier);
|
|
await tester.tap(
|
|
find.byKey(const ValueKey('theme-preview-appearance-action-button')),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.byKey(const ValueKey('theme-preview-close')));
|
|
await tester.pumpAndSettle();
|
|
expect(notifier.preferenceWrites, 0);
|
|
});
|
|
|
|
testWidgets(
|
|
'appearance cycles through Light Dark System with reduced motion',
|
|
(tester) async {
|
|
await _pumpPicker(tester, reduceMotion: true);
|
|
for (final label in ['Light', 'Dark', 'System']) {
|
|
await tester.tap(
|
|
find.byKey(const ValueKey('theme-preview-appearance-action-button')),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(find.text(label), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
class _RecordingCommunityThemeNotifier extends CommunityThemeNotifier {
|
|
int preferenceWrites = 0;
|
|
CommunityThemePreference? lastPreference;
|
|
|
|
@override
|
|
CommunityThemePreference build() => defaultCommunityTheme;
|
|
|
|
@override
|
|
void setPreference(CommunityThemePreference preference) {
|
|
preferenceWrites++;
|
|
lastPreference = preference;
|
|
state = preference;
|
|
}
|
|
}
|