Refresh mobile utility surfaces and theme picker (#6944)

## Summary

- invert mobile utility surfaces so pages and sheets use the softer page
background with raised containers
- rebuild the community theme picker around swipeable Home/Chat
previews, accent and appearance controls, and native iOS glass
interactions
- align shared sheet/profile spacing and radii, and move Theme into the
Community settings card

### Related issue

None found.

### Testing

- `just mobile-check`
- `just mobile-test` (1,871 tests)
- signed iOS Release build installed on a physical iPhone

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-08-28 18:56:42 +01:00
committed by GitHub
parent a89f5df4c5
commit ed11c8d8bf
47 changed files with 3765 additions and 631 deletions

View File

@@ -24,6 +24,7 @@
4A71C0172F40C00100A17E01 /* HuddleAudioEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0182F40C00100A17E01 /* HuddleAudioEngine.swift */; };
4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */; };
4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */; };
4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */; };
331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; };
331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; };
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; };
@@ -79,6 +80,7 @@
4A71C0182F40C00100A17E01 /* HuddleAudioEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuddleAudioEngine.swift; sourceTree = "<group>"; };
4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeProfileTextEditor.swift; sourceTree = "<group>"; };
4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSkinToneControl.swift; sourceTree = "<group>"; };
4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePaginationGlassControl.swift; sourceTree = "<group>"; };
331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = "<group>"; };
331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -212,6 +214,7 @@
4A71C0182F40C00100A17E01 /* HuddleAudioEngine.swift */,
4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */,
4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */,
4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
@@ -475,6 +478,7 @@
4A71C0172F40C00100A17E01 /* HuddleAudioEngine.swift in Sources */,
4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */,
4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */,
4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);

View File

@@ -138,6 +138,15 @@ import UserNotifications
)
}
if let themePaginationGlassRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzThemePaginationGlassControl"
) {
themePaginationGlassRegistrar.register(
ThemePaginationGlassControlFactory(messenger: messenger),
withId: "buzz/theme_pagination_glass"
)
}
let nativeAttachmentRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeAttachmentPopover"
)

View File

@@ -175,6 +175,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
private let channel: FlutterMethodChannel
private let button = NavigationGlassButton(type: .system)
private let activityIndicator = UIActivityIndicatorView(style: .medium)
private let swatchView = UIView()
private var buttonLabel: String?
private var buttonIconName = "chevron.backward"
private var contentIcon = "back"
@@ -248,6 +249,20 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
activityIndicator.heightAnchor.constraint(equalToConstant: 24),
])
swatchView.translatesAutoresizingMaskIntoConstraints = false
swatchView.isUserInteractionEnabled = false
swatchView.isHidden = true
swatchView.layer.cornerCurve = .continuous
button.addSubview(swatchView)
let swatchSize = max(0, controlSize - 8)
NSLayoutConstraint.activate([
swatchView.centerXAnchor.constraint(equalTo: button.centerXAnchor),
swatchView.centerYAnchor.constraint(equalTo: button.centerYAnchor),
swatchView.widthAnchor.constraint(equalToConstant: swatchSize),
swatchView.heightAnchor.constraint(equalToConstant: swatchSize),
])
swatchView.layer.cornerRadius = swatchSize / 2
applyAppearance(from: args)
channel.setMethodCallHandler { [weak self] call, result in
if call.method == "setContent" {
@@ -342,6 +357,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
let enabled = arguments?["enabled"] as? Bool ?? true
let busy = arguments?["busy"] as? Bool ?? false
let selected = arguments?["selected"] as? Bool ?? false
let swatchColorValue = (arguments?["swatchColor"] as? NSNumber)?.uint32Value
containerView.overrideUserInterfaceStyle = interfaceStyle
button.overrideUserInterfaceStyle = interfaceStyle
@@ -361,6 +377,9 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
button.configuration?.baseForegroundColor = foregroundColor
activityIndicator.color = foregroundColor
}
if let swatchColorValue {
swatchView.backgroundColor = Self.color(from: swatchColorValue)
}
if #unavailable(iOS 26.0) {
button.configuration?.baseBackgroundColor = Self.fallbackBackgroundColor(
foregroundColor: foregroundColor,
@@ -384,11 +403,16 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
case "camera": buttonIconName = "camera"
case "photoLibrary": buttonIconName = "photo.on.rectangle.angled"
case "palette": buttonIconName = "paintpalette"
case "droplet": buttonIconName = "drop.fill"
case "emoji": buttonIconName = "face.smiling"
case "person": buttonIconName = "person"
case "frame": buttonIconName = "rectangle.stack"
case "rotateCamera": buttonIconName = "arrow.triangle.2.circlepath.camera"
case "shutter": buttonIconName = "circle.fill"
case "sun": buttonIconName = "sun.max"
case "moon": buttonIconName = "moon"
case "systemAppearance": buttonIconName = "circle.lefthalf.filled"
case "colorSwatch": buttonIconName = "circle.fill"
default: buttonIconName = "chevron.backward"
}
if buttonLabel != nil {
@@ -457,10 +481,13 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
if isBusy {
button.configuration?.title = nil
button.configuration?.image = nil
swatchView.isHidden = true
activityIndicator.startAnimating()
} else {
button.configuration?.title = buttonLabel
button.configuration?.image = buttonLabel == nil ? buttonImage : nil
let displaysSwatch = contentIcon == "colorSwatch"
button.configuration?.title = displaysSwatch ? nil : buttonLabel
button.configuration?.image = displaysSwatch || buttonLabel != nil ? nil : buttonImage
swatchView.isHidden = !displaysSwatch
activityIndicator.stopAnimating()
}
}

View File

@@ -38,7 +38,10 @@ final class NativeProfileTextEditorCoordinator: NSObject,
let initialValue = arguments["initialValue"] as? String,
let placeholder = arguments["placeholder"] as? String,
let multiline = arguments["multiline"] as? Bool,
let brightness = arguments["brightness"] as? String
let brightness = arguments["brightness"] as? String,
let pageBackgroundArgb = arguments["pageBackgroundArgb"] as? NSNumber,
let containerBackgroundArgb = arguments["containerBackgroundArgb"] as? NSNumber,
let containerCornerRadius = arguments["containerCornerRadius"] as? NSNumber
else {
result(
FlutterError(
@@ -59,6 +62,9 @@ final class NativeProfileTextEditorCoordinator: NSObject,
placeholder: placeholder,
multiline: multiline,
brightness: brightness,
pageBackgroundColor: UIColor(argb: pageBackgroundArgb.uint32Value),
containerBackgroundColor: UIColor(argb: containerBackgroundArgb.uint32Value),
containerCornerRadius: CGFloat(containerCornerRadius.doubleValue),
allowUnchangedSubmission: allowUnchangedSubmission,
result: result
)
@@ -72,6 +78,9 @@ final class NativeProfileTextEditorCoordinator: NSObject,
placeholder: String,
multiline: Bool,
brightness: String,
pageBackgroundColor: UIColor,
containerBackgroundColor: UIColor,
containerCornerRadius: CGFloat,
allowUnchangedSubmission: Bool,
result: @escaping FlutterResult
) {
@@ -105,6 +114,9 @@ final class NativeProfileTextEditorCoordinator: NSObject,
initialValue: initialValue,
placeholder: placeholder,
multiline: multiline,
pageBackgroundColor: pageBackgroundColor,
containerBackgroundColor: containerBackgroundColor,
containerCornerRadius: containerCornerRadius,
allowUnchangedSubmission: allowUnchangedSubmission,
onCancel: { [weak self] in self?.finish(value: nil) },
onSet: { [weak self] value in self?.finish(value: value) }
@@ -184,6 +196,9 @@ private final class NativeProfileTextEditorViewController:
private let initialValue: String
private let placeholder: String
private let multiline: Bool
private let pageBackgroundColor: UIColor
private let containerBackgroundColor: UIColor
private let containerCornerRadius: CGFloat
private let allowUnchangedSubmission: Bool
private let onCancel: () -> Void
private let onSet: (String) -> Void
@@ -233,6 +248,9 @@ private final class NativeProfileTextEditorViewController:
initialValue: String,
placeholder: String,
multiline: Bool,
pageBackgroundColor: UIColor,
containerBackgroundColor: UIColor,
containerCornerRadius: CGFloat,
allowUnchangedSubmission: Bool,
onCancel: @escaping () -> Void,
onSet: @escaping (String) -> Void
@@ -240,6 +258,9 @@ private final class NativeProfileTextEditorViewController:
self.initialValue = initialValue
self.placeholder = placeholder
self.multiline = multiline
self.pageBackgroundColor = pageBackgroundColor
self.containerBackgroundColor = containerBackgroundColor
self.containerCornerRadius = containerCornerRadius
self.allowUnchangedSubmission = allowUnchangedSubmission
self.onCancel = onCancel
self.onSet = onSet
@@ -254,8 +275,22 @@ private final class NativeProfileTextEditorViewController:
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = pageBackgroundColor
tableView.backgroundColor = pageBackgroundColor
tableView.keyboardDismissMode = .interactive
tableView.alwaysBounceVertical = false
let navigationAppearance = UINavigationBarAppearance()
navigationAppearance.configureWithOpaqueBackground()
navigationAppearance.backgroundColor = pageBackgroundColor
navigationAppearance.shadowColor = UIColor.separator.withAlphaComponent(0.2)
let scrollEdgeAppearance = UINavigationBarAppearance()
scrollEdgeAppearance.configureWithOpaqueBackground()
scrollEdgeAppearance.backgroundColor = pageBackgroundColor
scrollEdgeAppearance.shadowColor = .clear
navigationItem.standardAppearance = navigationAppearance
navigationItem.scrollEdgeAppearance = scrollEdgeAppearance
navigationItem.compactAppearance = navigationAppearance
navigationItem.compactScrollEdgeAppearance = scrollEdgeAppearance
navigationItem.leftBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .cancel,
target: self,
@@ -360,6 +395,10 @@ private final class NativeProfileTextEditorViewController:
) -> UITableViewCell {
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
cell.selectionStyle = .none
cell.backgroundColor = containerBackgroundColor
cell.layer.cornerRadius = containerCornerRadius
cell.layer.cornerCurve = .continuous
cell.clipsToBounds = true
if multiline {
cell.contentView.addSubview(textView)
textView.addSubview(placeholderLabel)
@@ -385,3 +424,14 @@ private final class NativeProfileTextEditorViewController:
return cell
}
}
private extension UIColor {
convenience init(argb: UInt32) {
self.init(
red: CGFloat((argb >> 16) & 0xff) / 255,
green: CGFloat((argb >> 8) & 0xff) / 255,
blue: CGFloat(argb & 0xff) / 255,
alpha: CGFloat((argb >> 24) & 0xff) / 255
)
}
}

View File

@@ -0,0 +1,277 @@
import Flutter
import UIKit
final class ThemePaginationGlassControlFactory: NSObject, FlutterPlatformViewFactory {
private let messenger: FlutterBinaryMessenger
init(messenger: FlutterBinaryMessenger) {
self.messenger = messenger
super.init()
}
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
FlutterStandardMessageCodec.sharedInstance()
}
func create(
withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
ThemePaginationGlassControlPlatformView(
frame: frame,
viewIdentifier: viewId,
arguments: args,
messenger: messenger
)
}
}
private final class ThemePaginationControl: UIControl {
private static let maximumVisibleDots = 7
private static let fullDotSize: CGFloat = 6
private static let selectedDotSize: CGFloat = 10
private static let dotSpacing: CGFloat = 6
private let glassView: UIVisualEffectView
private let dotsContainer = UIView()
private var dots: [UIView] = []
private var totalCount = 1
private var selectedIndex = 0
private var activeColor = UIColor.label
private var inactiveColor = UIColor.secondaryLabel.withAlphaComponent(0.32)
var onSelectionChanged: ((Int) -> Void)?
override init(frame: CGRect) {
if #available(iOS 26.0, *) {
let effect = UIGlassEffect(style: .regular)
effect.isInteractive = true
glassView = UIVisualEffectView(effect: effect)
} else {
glassView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
}
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
accessibilityTraits = [.adjustable]
accessibilityLabel = "Theme"
glassView.translatesAutoresizingMaskIntoConstraints = false
glassView.clipsToBounds = true
glassView.layer.cornerCurve = .continuous
glassView.isUserInteractionEnabled = false
addSubview(glassView)
dotsContainer.translatesAutoresizingMaskIntoConstraints = false
dotsContainer.clipsToBounds = true
dotsContainer.isUserInteractionEnabled = false
glassView.contentView.addSubview(dotsContainer)
NSLayoutConstraint.activate([
glassView.leadingAnchor.constraint(equalTo: leadingAnchor),
glassView.trailingAnchor.constraint(equalTo: trailingAnchor),
glassView.centerYAnchor.constraint(equalTo: centerYAnchor),
glassView.heightAnchor.constraint(equalToConstant: 30),
dotsContainer.leadingAnchor.constraint(equalTo: glassView.contentView.leadingAnchor, constant: 12),
dotsContainer.trailingAnchor.constraint(equalTo: glassView.contentView.trailingAnchor, constant: -12),
dotsContainer.topAnchor.constraint(equalTo: glassView.contentView.topAnchor),
dotsContainer.bottomAnchor.constraint(equalTo: glassView.contentView.bottomAnchor),
])
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleGesture(_:))))
addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(handleGesture(_:))))
}
@available(*, unavailable)
required init?(coder: NSCoder) {
nil
}
override func layoutSubviews() {
super.layoutSubviews()
glassView.layer.cornerRadius = glassView.bounds.height / 2
glassView.layoutIfNeeded()
dotsContainer.layoutIfNeeded()
updateDots(animated: false)
}
func apply(arguments: [String: Any], animated: Bool) {
let count = max(1, (arguments["count"] as? NSNumber)?.intValue ?? totalCount)
let selected = min(
max(0, (arguments["selected"] as? NSNumber)?.intValue ?? selectedIndex),
count - 1
)
if let value = arguments["activeColor"] as? NSNumber {
activeColor = Self.color(fromARGB: value.uint32Value)
}
if let value = arguments["inactiveColor"] as? NSNumber {
inactiveColor = Self.color(fromARGB: value.uint32Value)
}
totalCount = count
if count != dots.count {
rebuildDots(count: count)
}
selectedIndex = selected
accessibilityValue = "\(selected + 1) of \(count)"
let animateChanges = (arguments["animateChanges"] as? Bool) ?? true
updateDots(animated: animated && animateChanges)
}
override func accessibilityIncrement() {
select(index: min(selectedIndex + 1, totalCount - 1))
}
override func accessibilityDecrement() {
select(index: max(selectedIndex - 1, 0))
}
@objc private func handleGesture(_ recognizer: UIGestureRecognizer) {
guard totalCount > 0 else { return }
let location = recognizer.location(in: glassView)
let progress = max(0, min(0.999_999, location.x / max(1, glassView.bounds.width)))
select(index: Int(progress * CGFloat(totalCount)))
}
private func select(index: Int) {
guard index != selectedIndex, (0..<totalCount).contains(index) else { return }
selectedIndex = index
accessibilityValue = "\(index + 1) of \(totalCount)"
updateDots(animated: true)
onSelectionChanged?(index)
}
private func rebuildDots(count: Int) {
dots.forEach { dot in
dot.removeFromSuperview()
}
dots = (0..<count).map { _ in
let dot = UIView()
dot.bounds = CGRect(
x: 0,
y: 0,
width: Self.fullDotSize,
height: Self.fullDotSize
)
dot.layer.cornerRadius = Self.fullDotSize / 2
dot.layer.cornerCurve = .continuous
dot.isUserInteractionEnabled = false
dotsContainer.addSubview(dot)
return dot
}
}
private func updateDots(animated: Bool) {
guard !dots.isEmpty, dotsContainer.bounds.width > 0 else { return }
let visibleCount = min(totalCount, Self.maximumVisibleDots)
let maximumStart = max(0, totalCount - visibleCount)
let centerSlot = visibleCount / 2
let windowStart = min(max(0, selectedIndex - centerSlot), maximumStart)
let windowEnd = windowStart + visibleCount - 1
let hasEarlierDots = windowStart > 0
let hasLaterDots = windowEnd < totalCount - 1
let pitch = Self.fullDotSize + Self.dotSpacing
let trackWidth = CGFloat(visibleCount) * Self.fullDotSize
+ CGFloat(max(0, visibleCount - 1)) * Self.dotSpacing
let trackOrigin = (dotsContainer.bounds.width - trackWidth) / 2
let changes = {
for (page, dot) in self.dots.enumerated() {
let slot = page - windowStart
let isVisible = (0..<visibleCount).contains(slot)
var diameter = Self.fullDotSize
if page == self.selectedIndex {
diameter = Self.selectedDotSize
} else if (hasEarlierDots && slot == 0) ||
(hasLaterDots && slot == visibleCount - 1)
{
diameter = 2
} else if (hasEarlierDots && slot == 1) ||
(hasLaterDots && slot == visibleCount - 2)
{
diameter = 4
}
dot.center = CGPoint(
x: trackOrigin + Self.fullDotSize / 2 + CGFloat(slot) * pitch,
y: self.dotsContainer.bounds.midY
)
dot.alpha = isVisible ? 1 : 0
dot.backgroundColor = page == self.selectedIndex
? self.activeColor
: self.inactiveColor
dot.transform = CGAffineTransform(
scaleX: diameter / Self.fullDotSize,
y: diameter / Self.fullDotSize
)
dot.layer.zPosition = page == self.selectedIndex ? 1 : 0
}
}
guard animated, !UIAccessibility.isReduceMotionEnabled else {
changes()
return
}
UIView.animate(
withDuration: 0.15,
delay: 0,
options: [.beginFromCurrentState, .allowUserInteraction, .curveEaseInOut],
animations: changes
)
}
private static func color(fromARGB value: UInt32) -> UIColor {
UIColor(
red: CGFloat((value >> 16) & 0xFF) / 255,
green: CGFloat((value >> 8) & 0xFF) / 255,
blue: CGFloat(value & 0xFF) / 255,
alpha: CGFloat((value >> 24) & 0xFF) / 255
)
}
}
final class ThemePaginationGlassControlPlatformView: NSObject, FlutterPlatformView {
private let control: ThemePaginationControl
private let channel: FlutterMethodChannel
init(
frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?,
messenger: FlutterBinaryMessenger
) {
control = ThemePaginationControl(frame: frame)
channel = FlutterMethodChannel(
name: "buzz/theme_pagination_glass/\(viewId)",
binaryMessenger: messenger
)
super.init()
let arguments = args as? [String: Any] ?? [:]
applyBrightness(from: arguments["brightness"])
control.apply(arguments: arguments, animated: false)
control.onSelectionChanged = { [weak self] index in
self?.channel.invokeMethod("selected", arguments: index)
}
channel.setMethodCallHandler { [weak self] call, result in
guard call.method == "setState", let arguments = call.arguments as? [String: Any] else {
result(FlutterMethodNotImplemented)
return
}
self?.applyBrightness(from: arguments["brightness"])
self?.control.apply(arguments: arguments, animated: true)
result(nil)
}
}
func view() -> UIView {
control
}
private func applyBrightness(from value: Any?) {
let style: UIUserInterfaceStyle = value as? String == "dark" ? .dark : .light
control.overrideUserInterfaceStyle = style
}
deinit {
channel.setMethodCallHandler(nil)
}
}

View File

@@ -389,16 +389,25 @@ class App extends HookConsumerWidget {
}
}
Widget _buildSettingsPage(BuildContext context) => SettingsPage(
profileHeader: const SettingsProfileHeader(),
profileEditPageBuilder: (_) =>
const ProfileEditPage(startInPhotoEditor: true),
onEditDisplayName: showProfileDisplayNameEditor,
onEditProfileDescription: showProfileDescriptionEditor,
invitePageBuilder: (_) => const CommunityInvitePage(),
identityRecoveryPageBuilder: (_) =>
const PairingPage(addingCommunity: true, identityRecoveryOnly: true),
);
Widget _buildSettingsPage(BuildContext context) => const _SettingsPageContent();
class _SettingsPageContent extends ConsumerWidget {
const _SettingsPageContent();
@override
Widget build(BuildContext context, WidgetRef ref) {
return SettingsPage(
profileHeader: const SettingsProfileHeader(),
profileEditPageBuilder: (_) =>
const ProfileEditPage(startInPhotoEditor: true),
onEditDisplayName: showProfileDisplayNameEditor,
onEditProfileDescription: showProfileDescriptionEditor,
invitePageBuilder: (_) => const CommunityInvitePage(),
identityRecoveryPageBuilder: (_) =>
const PairingPage(addingCommunity: true, identityRecoveryOnly: true),
);
}
}
class _SplashScreen extends StatelessWidget {
const _SplashScreen();

View File

@@ -253,7 +253,7 @@ class ChannelDetailsPage extends HookConsumerWidget {
Navigator.canPop(context) &&
Theme.of(context).platform == TargetPlatform.iOS;
return FrostedScaffold(
backgroundColor: context.colors.surface,
useUtilitySurfaceTheme: true,
appBar: FrostedAppBar(
centerTitle: true,
leading: usesNativeIosGlassBackButton

View File

@@ -31,7 +31,7 @@ class CommunityInvitePage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final roleAsync = ref.watch(currentCommunityRoleProvider);
return FrostedScaffold(
backgroundColor: context.colors.surface,
useUtilitySurfaceTheme: true,
appBar: const FrostedAppBar(
centerTitle: true,
title: Text('Invite to community'),

View File

@@ -92,7 +92,7 @@ class InviteJoinSheet extends ConsumerWidget {
color: context.colors.surfaceContainerHighest.withValues(
alpha: 0.7,
),
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(Radii.container),
border: Border.all(color: context.colors.outlineVariant),
),
child: Text(

View File

@@ -1,5 +1,7 @@
import 'package:flutter/services.dart';
import '../../shared/theme/theme.dart';
/// Opens the native iOS form used to edit a single profile text field.
class IosProfileTextEditor {
IosProfileTextEditor._();
@@ -14,6 +16,8 @@ class IosProfileTextEditor {
required String placeholder,
required bool multiline,
required Brightness brightness,
required Color pageBackgroundColor,
required Color containerBackgroundColor,
bool allowUnchangedSubmission = false,
}) => _channel.invokeMethod<String>('present', {
'title': title,
@@ -21,6 +25,9 @@ class IosProfileTextEditor {
'placeholder': placeholder,
'multiline': multiline,
'brightness': brightness.name,
'pageBackgroundArgb': pageBackgroundColor.toARGB32(),
'containerBackgroundArgb': containerBackgroundColor.toARGB32(),
'containerCornerRadius': Radii.container,
'allowUnchangedSubmission': allowUnchangedSubmission,
});
@@ -32,6 +39,8 @@ class IosProfileTextEditor {
required String placeholder,
required bool multiline,
required Brightness brightness,
required Color pageBackgroundColor,
required Color containerBackgroundColor,
required Future<void> Function(String value) onSave,
required void Function() onSaveError,
bool Function(Object error)? shouldRetryOnError,
@@ -47,6 +56,8 @@ class IosProfileTextEditor {
placeholder: placeholder,
multiline: multiline,
brightness: brightness,
pageBackgroundColor: pageBackgroundColor,
containerBackgroundColor: containerBackgroundColor,
allowUnchangedSubmission: isRetry,
);
if (value == null) return;

View File

@@ -624,13 +624,6 @@ class _AvatarModeControl extends StatelessWidget {
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: BorderRadius.circular(Radii.full),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
),
),

View File

@@ -111,13 +111,17 @@ class ProfileEditPage extends HookConsumerWidget {
bool multiline = false,
}) async {
if (defaultTargetPlatform == TargetPlatform.iOS) {
final sheetTheme = utilitySurfaceThemeData(Theme.of(context));
try {
await IosProfileTextEditor.presentUntilSaved(
title: title,
initialValue: initialValue,
placeholder: hintText,
multiline: multiline,
brightness: Theme.of(context).brightness,
brightness: sheetTheme.brightness,
pageBackgroundColor: sheetTheme.colorScheme.surface,
containerBackgroundColor:
sheetTheme.colorScheme.surfaceContainerHighest,
onSave: onSave,
shouldRetryOnError: (error) =>
error is! ProfileCommunityChangedException,
@@ -279,7 +283,7 @@ class ProfileEditPage extends HookConsumerWidget {
}
},
child: FrostedScaffold(
backgroundColor: context.colors.surface,
useUtilitySurfaceTheme: true,
resizeToAvoidBottomInset: isEditingAvatar.value ? false : null,
appBar: FrostedAppBar(
centerTitle: true,

View File

@@ -95,13 +95,17 @@ Future<void> _showProfileTextEditor({
bool multiline = false,
}) async {
if (defaultTargetPlatform == TargetPlatform.iOS) {
final sheetTheme = utilitySurfaceThemeData(Theme.of(context));
try {
await IosProfileTextEditor.presentUntilSaved(
title: title,
initialValue: initialValue,
placeholder: hintText,
multiline: multiline,
brightness: Theme.of(context).brightness,
brightness: sheetTheme.brightness,
pageBackgroundColor: sheetTheme.colorScheme.surface,
containerBackgroundColor:
sheetTheme.colorScheme.surfaceContainerHighest,
onSave: onSave,
shouldRetryOnError: (error) =>
error is! ProfileCommunityChangedException,

View File

@@ -281,7 +281,7 @@ class _StatusInput extends ConsumerWidget {
border: Border.all(
color: context.colors.outlineVariant.withValues(alpha: 0.8),
),
borderRadius: BorderRadius.circular(Radii.card),
borderRadius: BorderRadius.circular(Radii.container),
),
child: Row(
children: [
@@ -294,7 +294,7 @@ class _StatusInput extends ConsumerWidget {
button: true,
label: 'Choose a status emoji',
child: InkWell(
borderRadius: BorderRadius.circular(Radii.card),
borderRadius: BorderRadius.circular(Radii.container),
onTap: enabled ? onChooseEmoji : null,
child: Center(child: _StatusEmojiPreview(emoji: emoji)),
),

View File

@@ -21,6 +21,7 @@ class AccentPickerPage extends ConsumerWidget {
final colorScheme = context.colors;
return FrostedScaffold(
useUtilitySurfaceTheme: true,
appBar: const FrostedAppBar(
centerTitle: true,
title: Text('Accent Color'),

View File

@@ -22,10 +22,8 @@ import '../../shared/widgets/ios_glass_navigation_button.dart';
import '../../shared/widgets/ios_glass_navigation_action.dart';
import '../../shared/widgets/immediate_page_route.dart';
import '../../shared/widgets/modal_presentation.dart';
import 'accent_picker_page.dart';
import 'theme_picker_page.dart';
part 'settings_page/appearance_section.dart';
part 'settings_page/community_section.dart';
part 'settings_page/connection_section.dart';
@@ -142,7 +140,7 @@ class SettingsPage extends HookConsumerWidget {
}
return FrostedScaffold(
backgroundColor: context.colors.surface,
useUtilitySurfaceTheme: true,
appBar: FrostedAppBar(
automaticallyImplyLeading: false,
horizontalInset: Grid.gutter,
@@ -214,7 +212,6 @@ class SettingsPage extends HookConsumerWidget {
children: [
profileHeader,
_CommunitySection(invitePageBuilder: invitePageBuilder),
const _AppearanceSection(),
_ConnectionSection(
identityRecoveryPageBuilder: identityRecoveryPageBuilder,
),

View File

@@ -1,128 +0,0 @@
part of '../settings_page.dart';
/// System / Light / Dark, mirroring desktop's appearance-mode selector.
const _modeOptions = <({ThemeMode mode, String label, IconData icon})>[
(mode: ThemeMode.system, label: 'System', icon: LucideIcons.sunMoon),
(mode: ThemeMode.light, label: 'Light', icon: LucideIcons.sun),
(mode: ThemeMode.dark, label: 'Dark', icon: LucideIcons.moon),
];
String _modeLabel(ThemeMode mode) =>
_modeOptions.firstWhere((option) => option.mode == mode).label;
class _AppearanceSection extends ConsumerWidget {
const _AppearanceSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final preference = ref.watch(communityThemeProvider);
final mode = preference.mode;
final schemeName = preference.theme;
final accentIndex = effectiveAccentIndex(
preference.theme,
preference.accent,
);
return AppListCard(
label: 'Style · This community',
verticalPadding: Grid.twelve,
children: [
AppListRow(
icon: LucideIcons.sunMoon,
title: 'Appearance',
value: _modeLabel(mode),
trailing: const _RowChevron(),
onTap: () => _showAppearanceModeSheet(context),
),
AppListRow(
icon: LucideIcons.palette,
title: 'Theme',
value: themeSelectionLabel(schemeName, mode),
trailing: const _RowChevron(),
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const ThemePickerPage()),
),
),
if (!isBuzzTheme(effectiveTheme(schemeName, mode)?.name ?? schemeName))
AppListRow(
icon: LucideIcons.droplet,
title: 'Accent color',
// The swatch *is* the value — naming the color as well would say the
// same thing twice, so it takes the chevron's place.
trailing: _AccentSwatch(accentIndex: accentIndex),
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const AccentPickerPage()),
),
),
],
);
}
}
void _showAppearanceModeSheet(BuildContext context) {
showBuzzModalBottomSheet<void>(
context: context,
title: 'Appearance',
showDragHandle: true,
builder: (_) => const _AppearanceModeSheet(),
);
}
/// Three choices is too few to warrant a page, so the appearance row opens a
/// sheet rather than pushing a route.
class _AppearanceModeSheet extends ConsumerWidget {
const _AppearanceModeSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final mode = ref.watch(communityThemeProvider).mode;
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final option in _modeOptions)
AppListRow(
icon: option.icon,
title: option.label,
trailing: option.mode == mode
? Icon(
LucideIcons.check,
size: 18,
color: context.colors.primary,
)
: null,
onTap: () {
ref.read(communityThemeProvider.notifier).setMode(option.mode);
Navigator.of(context).pop();
},
),
const SizedBox(height: Grid.xxs),
],
),
);
}
}
/// The selected accent, shown where a row would otherwise carry a chevron.
class _AccentSwatch extends StatelessWidget {
const _AccentSwatch({required this.accentIndex});
static const _size = 22.0;
final int accentIndex;
@override
Widget build(BuildContext context) {
return Container(
width: _size,
height: _size,
decoration: BoxDecoration(
color: accentColorForScheme(context.colors, accentIndex),
shape: BoxShape.circle,
border: Border.all(color: context.colors.outlineVariant),
),
);
}
}

View File

@@ -8,21 +8,32 @@ class _CommunitySection extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final roleAsync = ref.watch(currentCommunityRoleProvider);
if (!roleAsync.hasError && !canManageCommunityInvites(roleAsync.value)) {
return const SizedBox.shrink();
}
final canInvite =
roleAsync.hasError || canManageCommunityInvites(roleAsync.value);
final preference = ref.watch(communityThemeProvider);
return AppListCard(
label: 'Community',
verticalPadding: Grid.twelve,
children: [
if (canInvite)
AppListRow(
icon: LucideIcons.userPlus,
title: 'Invite to community',
trailing: const _RowChevron(),
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: invitePageBuilder)),
),
AppListRow(
icon: LucideIcons.userPlus,
title: 'Invite to community',
key: const ValueKey('community-theme-row'),
icon: LucideIcons.palette,
title: 'Theme',
value: themeSelectionLabel(preference.theme, preference.mode),
trailing: const _RowChevron(),
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: invitePageBuilder)),
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const ThemePickerPage()),
),
),
],
);

View File

@@ -0,0 +1,274 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/modal_presentation.dart';
const appearanceModeOptions = <({ThemeMode mode, String label, IconData icon})>[
(mode: ThemeMode.light, label: 'Light', icon: LucideIcons.sun),
(mode: ThemeMode.dark, label: 'Dark', icon: LucideIcons.moon),
(mode: ThemeMode.system, label: 'System', icon: LucideIcons.sunMoon),
];
String appearanceModeLabel(ThemeMode mode) =>
appearanceModeOptions.firstWhere((option) => option.mode == mode).label;
Future<void> showAppearanceModePickerSheet({
required BuildContext context,
required ThemeMode selectedMode,
required ValueChanged<ThemeMode> onSelected,
}) => showBuzzModalBottomSheet<void>(
context: context,
title: 'Appearance',
showDragHandle: true,
builder: (_) =>
_AppearanceModePicker(initialMode: selectedMode, onSelected: onSelected),
);
Future<void> showAccentColorPickerSheet({
required BuildContext context,
required ColorScheme colorScheme,
required int selectedIndex,
required ValueChanged<int> onSelected,
}) => showBuzzModalBottomSheet<void>(
context: context,
title: 'Accent color',
showDragHandle: true,
builder: (_) => _AccentColorPicker(
colorScheme: colorScheme,
initialIndex: selectedIndex,
onSelected: onSelected,
),
);
class _AppearanceModePicker extends HookWidget {
const _AppearanceModePicker({
required this.initialMode,
required this.onSelected,
});
final ThemeMode initialMode;
final ValueChanged<ThemeMode> onSelected;
@override
Widget build(BuildContext context) {
final selected = useState(initialMode);
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xxs,
Grid.gutter,
Grid.xs,
),
child: SizedBox(
height: 132,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (
var index = 0;
index < appearanceModeOptions.length;
index++
) ...[
if (index > 0) const SizedBox(width: Grid.xxs),
Expanded(
child: _AppearanceModeOption(
option: appearanceModeOptions[index],
selected:
appearanceModeOptions[index].mode == selected.value,
onTap: () {
final mode = appearanceModeOptions[index].mode;
unawaited(HapticFeedback.selectionClick());
selected.value = mode;
onSelected(mode);
},
),
),
],
],
),
),
),
);
}
}
class _AppearanceModeOption extends StatelessWidget {
const _AppearanceModeOption({
required this.option,
required this.selected,
required this.onTap,
});
final ({ThemeMode mode, String label, IconData icon}) option;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Semantics(
button: true,
selected: selected,
label: '${option.label} appearance',
child: Container(
key: ValueKey('appearance-mode-${option.mode.name}'),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.container),
),
foregroundDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.container),
border: selected
? Border.all(color: context.colors.primary, width: 2)
: null,
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.container),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Stack(
alignment: Alignment.center,
children: [
Transform.translate(
offset: const Offset(0, -Grid.twelve),
child: Icon(
option.icon,
key: ValueKey('appearance-mode-${option.mode.name}-icon'),
size: 34,
color: selected
? context.colors.primary
: context.colors.onSurface,
),
),
Positioned(
left: Grid.xxs,
right: Grid.xxs,
bottom: Grid.xxs,
child: Text(
option.label,
key: ValueKey('appearance-mode-${option.mode.name}-label'),
textAlign: TextAlign.center,
style: context.textTheme.labelLarge?.copyWith(
color: selected
? context.colors.primary
: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
),
);
}
class _AccentColorPicker extends HookWidget {
const _AccentColorPicker({
required this.colorScheme,
required this.initialIndex,
required this.onSelected,
});
final ColorScheme colorScheme;
final int initialIndex;
final ValueChanged<int> onSelected;
@override
Widget build(BuildContext context) {
final selected = useState(initialIndex);
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xxs,
Grid.gutter,
Grid.xs,
),
child: GridView.builder(
key: const ValueKey('accent-selection-grid'),
shrinkWrap: true,
primary: false,
padding: EdgeInsets.zero,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: Grid.xxs,
mainAxisSpacing: Grid.xs,
),
itemCount: accentColors.length,
itemBuilder: (context, index) => _AccentSheetOption(
index: index,
color: accentColorForScheme(colorScheme, index),
selected: selected.value == index,
onTap: () {
unawaited(HapticFeedback.selectionClick());
selected.value = index;
onSelected(index);
},
),
),
),
);
}
}
class _AccentSheetOption extends StatelessWidget {
const _AccentSheetOption({
required this.index,
required this.color,
required this.selected,
required this.onTap,
});
final int index;
final Color color;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Semantics(
label: '${accentColors[index].name} accent color',
button: true,
selected: selected,
child: InkResponse(
key: ValueKey('accent-option-$index'),
onTap: onTap,
radius: 36,
child: Center(
child: Container(
key: ValueKey('accent-swatch-$index'),
width: 64,
height: 64,
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: context.colors.outlineVariant),
),
child: selected
? DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: color.computeLuminance() > 0.55
? const Color(0xFF111111)
: Colors.white,
width: 3,
),
),
)
: null,
),
),
),
);
}

View File

@@ -1,251 +1,65 @@
import 'dart:async';
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:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/community/community_icon_provider.dart';
import '../../shared/community/community_provider.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/ios_glass_navigation_action.dart';
import '../../shared/widgets/ios_glass_navigation_button.dart';
import '../../shared/widgets/ios_glass_theme_pagination.dart';
import 'theme_option_sheets.dart';
/// Themes for the current appearance mode, organised the way desktop does it:
/// System offers the light/dark pairs as single entries, while Light and Dark
/// each list every theme of that brightness.
class ThemePickerPage extends HookConsumerWidget {
part 'theme_picker_page/theme_home_preview.dart';
part 'theme_picker_page/theme_preview_sheet.dart';
List<ThemeColors> _themeEntriesForMode(ThemeMode mode) {
final groups = themeGroups();
return switch (mode) {
ThemeMode.system => groups.paired,
ThemeMode.light => groups.light,
ThemeMode.dark => groups.dark,
};
}
String _themeLabelFor(ThemeColors theme, ThemeMode mode) =>
mode == ThemeMode.system ? pairedThemeLabel(theme.name) : theme.displayName;
ThemeColors _displayedThemeFor(
ThemeColors theme,
ThemeMode mode,
Brightness platformBrightness,
) {
if (mode != ThemeMode.system || platformBrightness == Brightness.light) {
return theme;
}
return findTheme(themePairFor(theme.name) ?? '') ?? theme;
}
bool _themeEntryIsSelected(
ThemeColors entry,
ThemeMode mode,
String selectedScheme,
) {
final active = effectiveTheme(selectedScheme, mode);
if (active == null) return false;
if (mode != ThemeMode.system) return active.name == entry.name;
return active.name == entry.name || themePairFor(entry.name) == active.name;
}
/// Direct, swipeable theme preview. Theme, accent, and appearance changes stay
/// as a draft until Set is pressed.
class ThemePickerPage extends StatelessWidget {
const ThemePickerPage({super.key});
// ListTile height (~56px) used for scroll offset calculation.
static const _itemHeight = 56.0;
@override
Widget build(BuildContext context, WidgetRef ref) {
final preference = ref.watch(communityThemeProvider);
final mode = preference.mode;
final selectedScheme = preference.theme;
final searchQuery = useState('');
final searchController = useTextEditingController();
final scrollController = useScrollController();
final groups = themeGroups();
final entries = switch (mode) {
ThemeMode.system => groups.paired,
ThemeMode.light => groups.light,
ThemeMode.dark => groups.dark,
};
final isSystem = mode == ThemeMode.system;
String labelFor(ThemeColors theme) =>
isSystem ? pairedThemeLabel(theme.name) : theme.displayName;
final query = searchQuery.value.toLowerCase();
final filtered = query.isEmpty
? entries
: entries
.where((t) => labelFor(t).toLowerCase().contains(query))
.toList();
// In System mode either half of a pair counts as the selection; in the
// pinned modes the effective theme already accounts for coercion, so the
// checkmark always names what is actually rendered.
final active = effectiveTheme(selectedScheme, mode);
bool isSelected(ThemeColors theme) {
if (active == null) return false;
if (!isSystem) return active.name == theme.name;
return active.name == theme.name ||
themePairFor(theme.name) == active.name;
}
// Auto-scroll to the selected theme on first build (no search active).
useEffect(() {
if (query.isNotEmpty) return null;
final idx = filtered.indexWhere(isSelected);
if (idx < 0) return null;
final offset = idx * _itemHeight;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
scrollController.animateTo(
offset.clamp(0.0, scrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
return null;
}, const []);
return FrostedScaffold(
appBar: const FrostedAppBar(centerTitle: true, title: Text('Theme')),
body: Column(
children: [
SizedBox(height: frostedAppBarHeight(context)),
_SearchField(
controller: searchController,
query: searchQuery.value,
onChanged: (v) => searchQuery.value = v,
onClear: () {
searchController.clear();
searchQuery.value = '';
},
),
Expanded(
child: filtered.isEmpty
? Padding(
padding: const EdgeInsets.all(Grid.sm),
child: Center(
child: Text(
'No themes found',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
)
: ListView.builder(
controller: scrollController,
itemCount: filtered.length,
itemBuilder: (_, i) {
final theme = filtered[i];
final pairName = isSystem
? themePairFor(theme.name)
: null;
return _ThemeRow(
theme: theme,
pair: pairName == null ? null : findTheme(pairName),
label: labelFor(theme),
selected: isSelected(theme),
onTap: () => ref
.read(communityThemeProvider.notifier)
.setTheme(theme.name),
);
},
),
),
],
),
);
}
}
class _SearchField extends StatelessWidget {
const _SearchField({
required this.controller,
required this.query,
required this.onChanged,
required this.onClear,
});
final TextEditingController controller;
final String query;
final ValueChanged<String> onChanged;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.xxs,
),
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.lg),
border: Border.all(color: context.colors.outlineVariant),
),
child: Row(
children: [
Icon(
LucideIcons.search,
size: 16,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.xxs),
Expanded(
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: 'Search themes...',
hintStyle: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
style: context.textTheme.bodyMedium,
onChanged: onChanged,
),
),
if (query.isNotEmpty)
GestureDetector(
onTap: onClear,
child: Icon(
LucideIcons.x,
size: 16,
color: context.colors.onSurfaceVariant,
),
),
],
),
),
);
}
}
/// A theme row with a colour bar + name + checkmark. When [pair] is supplied the
/// bar is split down the middle — light stripes on the left, dark on the right —
/// standing in for desktop's split light/dark preview tile.
class _ThemeRow extends StatelessWidget {
const _ThemeRow({
required this.theme,
required this.pair,
required this.label,
required this.selected,
required this.onTap,
});
final ThemeColors theme;
final ThemeColors? pair;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final swatch = pair == null ? [theme] : [theme, pair!];
return ListTile(
leading: Container(
width: 56,
height: 28,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.sm),
border: Border.all(color: context.colors.outlineVariant),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(Radii.sm - 1),
child: Row(
children: [
for (final t in swatch)
for (final color in [t.bg, t.fg, t.comment])
Expanded(
child: ColoredBox(
color: color,
child: const SizedBox.expand(),
),
),
],
),
),
),
title: Text(label),
trailing: selected
? Icon(LucideIcons.check, size: 18, color: context.colors.primary)
: null,
onTap: onTap,
);
}
Widget build(BuildContext context) => const _ThemePreviewExperience();
}

View File

@@ -0,0 +1,505 @@
part of '../theme_picker_page.dart';
/// A compact preview container holding complete Home and Chat device frames.
///
/// Each 393 x 852 frame uses `contain` within the compact outer card, keeping
/// both complete devices in view with breathing room instead of filling it.
class _ThemeHomePreview extends StatelessWidget {
const _ThemeHomePreview({
required this.colorScheme,
required this.topSectionGradient,
});
static const deviceSize = Size(393, 852);
final ColorScheme colorScheme;
final Gradient? topSectionGradient;
@override
Widget build(BuildContext context) {
final previewTheme = colorScheme.brightness == Brightness.dark
? AppTheme.dark(
colorScheme: colorScheme,
topSectionGradient: topSectionGradient,
)
: AppTheme.light(
colorScheme: colorScheme,
topSectionGradient: topSectionGradient,
);
return Container(
key: const ValueKey('theme-home-preview'),
padding: const EdgeInsets.symmetric(vertical: Grid.twelve),
decoration: BoxDecoration(
color: context.colors.surfaceContainerLow,
borderRadius: BorderRadius.circular(Radii.container),
),
clipBehavior: Clip.antiAlias,
child: FittedBox(
fit: BoxFit.contain,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_PreviewDeviceFrame(
key: const ValueKey('theme-home-device-preview'),
theme: previewTheme,
semanticLabel: 'Home preview',
child: const _FigmaHomeScreen(),
),
const SizedBox(width: 96),
_PreviewDeviceFrame(
key: const ValueKey('theme-chat-device-preview'),
theme: previewTheme,
semanticLabel: 'Chat preview',
child: const _FigmaChatScreen(),
),
],
),
),
);
}
}
class _PreviewDeviceFrame extends StatelessWidget {
const _PreviewDeviceFrame({
required this.theme,
required this.semanticLabel,
required this.child,
super.key,
});
final ThemeData theme;
final String semanticLabel;
final Widget child;
@override
Widget build(BuildContext context) => Semantics(
container: true,
excludeSemantics: true,
label: semanticLabel,
image: true,
child: Padding(
padding: const EdgeInsets.all(2),
child: Container(
width: _ThemeHomePreview.deviceSize.width,
height: _ThemeHomePreview.deviceSize.height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(40),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.72),
width: 2,
strokeAlign: BorderSide.strokeAlignOutside,
),
),
clipBehavior: Clip.none,
child: ClipRRect(
borderRadius: BorderRadius.circular(40),
clipBehavior: Clip.antiAlias,
child: Theme(data: theme, child: child),
),
),
),
);
}
class _FigmaHomeScreen extends ConsumerWidget {
const _FigmaHomeScreen();
static const _rowTops = <double>[
116,
152,
188,
224,
266,
302,
338,
374,
416,
452,
488,
524,
];
static const _capsuleWidths = <double>[
69,
69,
107,
91,
69,
69,
107,
91,
69,
69,
107,
91,
];
@override
Widget build(BuildContext context, WidgetRef ref) {
final scheme = context.colors;
final community = ref.watch(activeCommunityProvider).value;
final communityName = community?.name.trim();
final relayUrl = community?.relayUrl;
final communityIcon = relayUrl == null
? null
: ref.watch(communityIconProvider(relayUrl)).value;
final suppliedGradient = context.appColors.topSectionGradient;
final backgroundGradient =
suppliedGradient ??
LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [scheme.surfaceContainerLowest, scheme.surface],
);
final channelForeground = navigationPrimaryForeground(
context,
).withValues(alpha: 0.38);
final sectionForeground = navigationSectionForeground(
context,
).withValues(alpha: 0.52);
final chrome = scheme.surfaceContainerHighest;
return ColoredBox(
key: const ValueKey('theme-preview-surface'),
color: scheme.surface,
child: DecoratedBox(
decoration: BoxDecoration(gradient: backgroundGradient),
child: Stack(
children: [
Positioned(
key: const ValueKey('theme-preview-community-identity'),
left: 12,
top: 38,
child: Row(
children: [
AvatarImage(
key: const ValueKey('theme-preview-community-icon'),
imageUrl: communityIcon,
radius: 20,
backgroundColor: scheme.primaryContainer,
fallback: Text(
communityName == null || communityName.isEmpty
? '?'
: communityName.substring(0, 1).toUpperCase(),
style: context.textTheme.labelMedium?.copyWith(
color: scheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: Grid.twelve),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 210),
child: Text(
communityName == null || communityName.isEmpty
? 'Community'
: communityName,
key: const ValueKey('theme-preview-community-name'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleMedium?.copyWith(
color: navigationPrimaryForeground(context),
fontWeight: FontWeight.w600,
),
),
),
],
),
),
for (var index = 0; index < _rowTops.length; index++)
Positioned(
left: index % 4 == 0 ? 22 : 38,
top: _rowTops[index],
child: _PreviewChannelSkeletonRow(
key: index % 4 == 0
? ValueKey('theme-preview-section-${index ~/ 4}')
: ValueKey('theme-preview-channel-$index'),
capsuleKey: index % 4 == 0
? ValueKey('theme-preview-section-${index ~/ 4}-capsule')
: ValueKey('theme-preview-channel-$index-capsule'),
color: index % 4 == 0 ? sectionForeground : channelForeground,
capsuleWidth: _capsuleWidths[index],
gap: index % 4 == 0 ? 10 : 8,
),
),
_PreviewPill(
key: const ValueKey('theme-preview-bottom-navigation'),
left: 93,
top: 767,
width: 204,
height: 54,
color: chrome,
),
_PreviewPill(
key: const ValueKey('theme-preview-accent-action'),
left: 317,
top: 767,
width: 54,
height: 54,
color: scheme.primary,
),
],
),
),
);
}
}
class _PreviewChannelSkeletonRow extends StatelessWidget {
const _PreviewChannelSkeletonRow({
required this.color,
required this.capsuleWidth,
required this.gap,
this.capsuleKey,
super.key,
});
final Color color;
final double capsuleWidth;
final double gap;
final Key? capsuleKey;
@override
Widget build(BuildContext context) => Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
SizedBox(width: gap),
Container(
key: capsuleKey,
width: capsuleWidth,
height: 11,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(Radii.full),
),
),
],
);
}
class _FigmaChatScreen extends StatelessWidget {
const _FigmaChatScreen();
@override
Widget build(BuildContext context) {
final scheme = context.colors;
final chrome = scheme.surfaceContainerHighest;
final text = scheme.onSurface.withValues(alpha: 0.9);
final muted = scheme.onSurfaceVariant.withValues(alpha: 0.58);
return ColoredBox(
key: const ValueKey('theme-chat-preview-surface'),
color: scheme.surface,
child: Stack(
children: [
_PreviewPill(left: 16, top: 28, width: 38, height: 38, color: chrome),
_PreviewPill(
key: const ValueKey('theme-chat-header-title'),
left: 70,
top: 31,
width: 112,
height: 13,
color: text,
),
_PreviewPill(
key: const ValueKey('theme-chat-header-members'),
left: 70,
top: 51,
width: 66,
height: 9,
color: muted,
),
_PreviewPill(
left: 339,
top: 28,
width: 38,
height: 38,
color: chrome,
),
_PreviewPill(
left: 165,
top: 112,
width: 64,
height: 18,
color: chrome,
),
_PreviewTimelineMessage(
key: const ValueKey('theme-chat-timeline-message-1'),
top: 166,
avatarColor: chrome,
authorWidth: 92,
bodyWidths: const [218, 164],
),
_PreviewTimelineMessage(
key: const ValueKey('theme-chat-timeline-message-2'),
top: 288,
avatarColor: chrome,
authorWidth: 118,
bodyWidths: const [244, 198, 126],
),
_PreviewTimelineMessage(
key: const ValueKey('theme-chat-timeline-message-3'),
top: 432,
avatarColor: chrome,
authorWidth: 76,
bodyWidths: const [206, 148],
),
Positioned(
key: const ValueKey('theme-chat-composer-preview'),
left: 16,
right: 16,
bottom: 30,
height: 52,
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
border: Border.all(color: Colors.black.withValues(alpha: 0.04)),
),
child: Stack(
children: [
_PreviewPill(
left: 20,
top: 20,
width: 104,
height: 12,
color: muted,
),
Positioned(
key: const ValueKey('theme-chat-send-action'),
right: Grid.xxs,
top: 8,
width: 36,
height: 36,
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.primary,
shape: BoxShape.circle,
),
),
),
],
),
),
),
],
),
);
}
}
class _PreviewTimelineMessage extends StatelessWidget {
const _PreviewTimelineMessage({
required this.top,
required this.avatarColor,
required this.authorWidth,
required this.bodyWidths,
super.key,
});
final double top;
final Color avatarColor;
final double authorWidth;
final List<double> bodyWidths;
@override
Widget build(BuildContext context) {
final scheme = context.colors;
return Positioned(
left: 18,
right: 18,
top: top,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: avatarColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: authorWidth,
height: 13,
decoration: BoxDecoration(
color: scheme.onSurface.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(8),
),
),
const SizedBox(width: 10),
Container(
width: 42,
height: 9,
decoration: BoxDecoration(
color: scheme.onSurfaceVariant.withValues(alpha: 0.58),
borderRadius: BorderRadius.circular(8),
),
),
],
),
const SizedBox(height: 12),
for (final width in bodyWidths) ...[
Container(
width: width,
height: 11,
decoration: BoxDecoration(
color: scheme.onSurface.withValues(alpha: 0.88),
borderRadius: BorderRadius.circular(8),
),
),
const SizedBox(height: 9),
],
],
),
),
],
),
);
}
}
class _PreviewPill extends StatelessWidget {
const _PreviewPill({
required this.left,
required this.top,
required this.width,
required this.height,
required this.color,
super.key,
});
final double left;
final double top;
final double width;
final double height;
final Color color;
@override
Widget build(BuildContext context) {
return Positioned(
left: left,
top: top,
width: width,
height: height,
child: DecoratedBox(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(80),
),
),
);
}
}

View File

@@ -0,0 +1,814 @@
part of '../theme_picker_page.dart';
const _appearanceOrder = [ThemeMode.light, ThemeMode.dark, ThemeMode.system];
const _appearanceMotionDuration = Duration(milliseconds: 150);
const _appearanceMotionCurve = Cubic(0.23, 1, 0.32, 1);
const _iosThemeScrubberWidth = 116.0;
class _ThemePreviewExperience extends HookConsumerWidget {
const _ThemePreviewExperience();
@override
Widget build(BuildContext context, WidgetRef ref) {
final stored = ref.read(communityThemeProvider);
final initialEntries = _themeEntriesForMode(stored.mode);
var initialIndex = initialEntries.indexWhere(
(entry) => _themeEntryIsSelected(entry, stored.mode, stored.theme),
);
if (initialIndex < 0) initialIndex = 0;
final draftMode = useState(stored.mode);
final draftThemeName = useState(initialEntries[initialIndex].name);
final draftAccent = useState(
accentIndexForWireValue(stored.accent) ?? defaultAccentIndex,
);
final currentPage = useState(initialIndex);
final suppressNextPageHaptic = useRef(false);
final controller = usePageController(initialPage: initialIndex);
final entries = _themeEntriesForMode(draftMode.value);
final platformBrightness = MediaQuery.platformBrightnessOf(context);
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final previousDraftMode = usePrevious(draftMode.value);
final animatePaginationChanges =
previousDraftMode == null || previousDraftMode == draftMode.value;
var selectedIndex = entries.indexWhere(
(entry) =>
_themeEntryIsSelected(entry, draftMode.value, draftThemeName.value),
);
if (selectedIndex < 0) selectedIndex = 0;
final selectedEntry = entries[selectedIndex];
final selectedDisplayed = _displayedThemeFor(
selectedEntry,
draftMode.value,
platformBrightness,
);
final selectedResolved = resolveSchemes(
selectedEntry.name,
draftMode.value,
);
final selectedBaseScheme = platformBrightness == Brightness.dark
? selectedResolved.dark
: selectedResolved.light;
final supportsAccent = !isBuzzTheme(selectedDisplayed.name);
void updateMode(ThemeMode mode) {
final normalized = mode == ThemeMode.system
? schemeForAppearanceMode(draftThemeName.value, mode)
: effectiveTheme(draftThemeName.value, mode)?.name;
final nextEntries = _themeEntriesForMode(mode);
final nextTheme = normalized ?? nextEntries.first.name;
var nextIndex = nextEntries.indexWhere(
(entry) => _themeEntryIsSelected(entry, mode, nextTheme),
);
if (nextIndex < 0) nextIndex = 0;
draftMode.value = mode;
draftThemeName.value = nextEntries[nextIndex].name;
if (nextIndex != currentPage.value) {
suppressNextPageHaptic.value = true;
}
currentPage.value = nextIndex;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (controller.hasClients) controller.jumpToPage(nextIndex);
});
}
void cycleAppearance() {
final index = _appearanceOrder.indexOf(draftMode.value);
final next = _appearanceOrder[(index + 1) % _appearanceOrder.length];
unawaited(HapticFeedback.selectionClick());
updateMode(next);
}
void applySelection() {
unawaited(HapticFeedback.mediumImpact());
final notifier = ref.read(communityThemeProvider.notifier);
final currentAccent = ref.read(communityThemeProvider).accent;
notifier.setPreference(
CommunityThemePreference(
theme: selectedEntry.name,
accent: supportsAccent
? accentColors[draftAccent.value].wireValue
: currentAccent,
followSystem: draftMode.value == ThemeMode.system,
),
);
Navigator.of(context).pop();
}
void close() {
unawaited(HapticFeedback.lightImpact());
Navigator.of(context).pop();
}
return FrostedScaffold(
useUtilitySurfaceTheme: true,
appBar: FrostedAppBar(
automaticallyImplyLeading: false,
centerTitle: true,
showBottomDivider: false,
leading: _ThemePreviewCloseButton(onPressed: close),
title: const Text('Theme'),
actions: [_ThemePreviewSetButton(onPressed: applySelection)],
),
body: Column(
children: [
SizedBox(height: frostedAppBarHeight(context)),
Expanded(
child: PageView.builder(
key: const ValueKey('theme-preview-pages'),
controller: controller,
itemCount: entries.length,
onPageChanged: (index) {
if (suppressNextPageHaptic.value) {
suppressNextPageHaptic.value = false;
} else if (index != currentPage.value) {
unawaited(HapticFeedback.selectionClick());
}
currentPage.value = index;
draftThemeName.value = entries[index].name;
},
itemBuilder: (context, index) => AnimatedBuilder(
animation: controller,
builder: (context, _) {
var titleOffset = 0.0;
if (!reduceMotion && controller.hasClients) {
final page = controller.page;
if (page != null) {
titleOffset = (index - page).clamp(-1.0, 1.0) * Grid.md;
}
}
return _ThemePreviewPage(
theme: entries[index],
mode: draftMode.value,
accentIndex: draftAccent.value,
platformBrightness: platformBrightness,
titleHorizontalOffset: titleOffset,
);
},
),
),
),
SafeArea(
top: false,
minimum: const EdgeInsets.only(bottom: Grid.xs),
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.twelve,
Grid.gutter,
0,
),
child: Row(
children: [
SizedBox(
width: 64,
child: IgnorePointer(
ignoring: !supportsAccent,
child: AnimatedSwitcher(
key: const ValueKey(
'theme-preview-accent-availability',
),
duration: reduceMotion
? Duration.zero
: const Duration(milliseconds: 180),
reverseDuration: reduceMotion
? Duration.zero
: const Duration(milliseconds: 140),
switchInCurve: _appearanceMotionCurve,
switchOutCurve: Curves.easeInOutCubic,
layoutBuilder: (currentChild, previousChildren) =>
Stack(
alignment: Alignment.center,
children: [...previousChildren, ?currentChild],
),
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween(
begin: 0.78,
end: 1.0,
).animate(animation),
child: child,
),
),
child: supportsAccent
? _PreviewSheetCircleAction(
key: const ValueKey(
'theme-preview-accent-action-button',
),
semanticLabel: 'Accent color',
color: accentColorForScheme(
selectedBaseScheme,
draftAccent.value,
),
onTap: () {
unawaited(HapticFeedback.lightImpact());
showAccentColorPickerSheet(
context: context,
colorScheme: selectedBaseScheme,
selectedIndex: draftAccent.value,
onSelected: (index) =>
draftAccent.value = index,
);
},
)
: const SizedBox(
key: ValueKey(
'theme-preview-accent-unavailable',
),
),
),
),
),
Expanded(
child: _ThemeScrubber(
count: entries.length,
selected: currentPage.value.clamp(0, entries.length - 1),
animateChanges: animatePaginationChanges,
onSelected: (index) {
if (index == currentPage.value ||
!controller.hasClients) {
return;
}
controller.jumpToPage(index);
},
),
),
SizedBox(
width: 64,
child: _AppearanceCycleAction(
mode: draftMode.value,
reduceMotion: reduceMotion,
onTap: cycleAppearance,
),
),
],
),
),
),
],
),
);
}
}
class _ThemePreviewPage extends StatelessWidget {
const _ThemePreviewPage({
required this.theme,
required this.mode,
required this.accentIndex,
required this.platformBrightness,
required this.titleHorizontalOffset,
});
final ThemeColors theme;
final ThemeMode mode;
final int accentIndex;
final Brightness platformBrightness;
final double titleHorizontalOffset;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(Grid.gutter, Grid.xxs, Grid.gutter, 0),
child: DecoratedBox(
key: ValueKey('theme-preview-page-${theme.name}'),
decoration: BoxDecoration(
color: context.colors.surfaceContainerLow,
borderRadius: BorderRadius.circular(Radii.container),
),
child: LayoutBuilder(
builder: (context, constraints) {
const previewWidth = 858.0;
const previewHeight = 852.0;
final availableWidth = constraints.maxWidth - Grid.xs * 2;
final availableHeight = constraints.maxHeight - Grid.xs * 2;
final widthScale = availableWidth / previewWidth;
final heightScale = availableHeight / previewHeight;
final scale = widthScale < heightScale ? widthScale : heightScale;
final previewTop =
(constraints.maxHeight - previewHeight * scale) / 2;
final desiredTitleBottom =
constraints.maxHeight - previewTop + Grid.md;
final landscapeTitleBottom =
constraints.maxHeight - Grid.xs - Grid.lg;
final titleBottom =
heightScale < widthScale &&
desiredTitleBottom > landscapeTitleBottom
? landscapeTitleBottom
: desiredTitleBottom;
return Stack(
children: [
Positioned.fill(
child: Padding(
padding: const EdgeInsets.all(Grid.xs),
child: FittedBox(
key: ValueKey('theme-full-preview-${theme.name}'),
fit: BoxFit.contain,
alignment: Alignment.center,
child: _ThemeDevicePairPreview(
theme: theme,
mode: mode,
accentIndex: accentIndex,
platformBrightness: platformBrightness,
),
),
),
),
Positioned(
left: Grid.xs,
right: Grid.xs,
bottom: titleBottom,
child: Transform.translate(
key: ValueKey('theme-preview-name-motion-${theme.name}'),
offset: Offset(titleHorizontalOffset, 0),
child: Text(
_themeLabelFor(theme, mode),
key: ValueKey('theme-preview-name-${theme.name}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
),
],
);
},
),
),
);
}
class _ThemeDevicePairPreview extends StatelessWidget {
const _ThemeDevicePairPreview({
required this.theme,
required this.mode,
required this.accentIndex,
required this.platformBrightness,
});
final ThemeColors theme;
final ThemeMode mode;
final int accentIndex;
final Brightness platformBrightness;
@override
Widget build(BuildContext context) {
final displayed = _displayedThemeFor(theme, mode, platformBrightness);
final resolved = resolveSchemes(theme.name, mode);
final base = platformBrightness == Brightness.dark
? resolved.dark
: resolved.light;
final effectiveAccent = isBuzzTheme(displayed.name)
? defaultAccentIndex
: accentIndex;
final scheme = applyAccent(base, effectiveAccent);
final gradient = buzzTopSectionGradient(displayed.name, scheme.brightness);
final previewTheme = scheme.brightness == Brightness.dark
? AppTheme.dark(colorScheme: scheme, topSectionGradient: gradient)
: AppTheme.light(colorScheme: scheme, topSectionGradient: gradient);
return Row(
key: ValueKey('theme-device-pair-preview-${theme.name}'),
mainAxisSize: MainAxisSize.min,
children: [
_PreviewDeviceFrame(
key: ValueKey('theme-full-home-${theme.name}'),
theme: previewTheme,
semanticLabel: 'Home preview',
child: const _FigmaHomeScreen(),
),
const SizedBox(width: 72),
_PreviewDeviceFrame(
key: ValueKey('theme-full-chat-${theme.name}'),
theme: previewTheme,
semanticLabel: 'Chat preview',
child: const _FigmaChatScreen(),
),
],
);
}
}
class _ThemePreviewCloseButton extends StatelessWidget {
const _ThemePreviewCloseButton({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
if (defaultTargetPlatform == TargetPlatform.iOS) {
return IosGlassNavigationButton(
key: const ValueKey('theme-preview-close'),
icon: IosGlassNavigationIcon.close,
semanticLabel: 'Close preview',
onPressed: onPressed,
width: 44,
height: 44,
foregroundColor: navigationPrimaryForeground(context),
);
}
return SizedBox.square(
dimension: 44,
child: IconButton(
key: const ValueKey('theme-preview-close'),
tooltip: 'Close preview',
onPressed: onPressed,
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
backgroundColor: context.colors.surfaceContainerHighest,
foregroundColor: navigationPrimaryForeground(context),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.dialog),
),
),
icon: const Icon(LucideIcons.x, size: 22),
),
);
}
}
class _ThemePreviewSetButton extends StatelessWidget {
const _ThemePreviewSetButton({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final foreground = navigationPrimaryForeground(context);
if (defaultTargetPlatform == TargetPlatform.iOS) {
return IosGlassNavigationAction(
key: const ValueKey('theme-preview-set'),
label: 'Set',
width: 64,
height: 44,
foregroundColor: foreground,
onPressed: onPressed,
);
}
return Material(
key: const ValueKey('theme-preview-set'),
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.full),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onPressed,
child: SizedBox(
height: 44,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
child: Center(
child: Text(
'Set',
style: context.textTheme.labelLarge?.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
);
}
}
class _PreviewSheetCircleAction extends StatelessWidget {
const _PreviewSheetCircleAction({
super.key,
required this.semanticLabel,
required this.onTap,
required this.color,
});
final String semanticLabel;
final VoidCallback onTap;
final Color color;
@override
Widget build(BuildContext context) {
if (defaultTargetPlatform == TargetPlatform.iOS) {
return IosGlassNavigationButton(
icon: IosGlassNavigationIcon.colorSwatch,
semanticLabel: semanticLabel,
onPressed: onTap,
width: 64,
height: 54,
controlSize: 54,
buttonCenterX: 32,
foregroundColor: context.colors.onSurface,
swatchColor: color,
);
}
return Semantics(
label: semanticLabel,
button: true,
child: InkResponse(
onTap: onTap,
radius: 30,
child: Center(
child: SizedBox.square(
dimension: 54,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
shape: BoxShape.circle,
border: Border.all(
color: context.colors.outlineVariant,
strokeAlign: BorderSide.strokeAlignOutside,
),
),
child: DecoratedBox(
key: const ValueKey('theme-preview-accent-swatch'),
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
),
),
),
),
);
}
}
class _AppearanceCycleAction extends StatelessWidget {
const _AppearanceCycleAction({
required this.mode,
required this.reduceMotion,
required this.onTap,
});
final ThemeMode mode;
final bool reduceMotion;
final VoidCallback onTap;
IconData get _icon => switch (mode) {
ThemeMode.light => LucideIcons.sun,
ThemeMode.dark => LucideIcons.moon,
ThemeMode.system => LucideIcons.sunMoon,
};
IosGlassNavigationIcon get _iosIcon => switch (mode) {
ThemeMode.light => IosGlassNavigationIcon.sun,
ThemeMode.dark => IosGlassNavigationIcon.moon,
ThemeMode.system => IosGlassNavigationIcon.systemAppearance,
};
@override
Widget build(BuildContext context) {
final semanticLabel =
'${appearanceModeLabel(mode)} appearance. Double tap to change.';
if (defaultTargetPlatform == TargetPlatform.iOS) {
return IosGlassNavigationButton(
key: const ValueKey('theme-preview-appearance-action-button'),
icon: _iosIcon,
semanticLabel: semanticLabel,
onPressed: onTap,
width: 64,
height: 54,
controlSize: 54,
buttonCenterX: 32,
foregroundColor: context.colors.onSurface,
);
}
return Semantics(
label: semanticLabel,
button: true,
child: InkResponse(
key: const ValueKey('theme-preview-appearance-action-button'),
onTap: onTap,
radius: 30,
child: Container(
width: 54,
height: 54,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
shape: BoxShape.circle,
border: Border.all(color: context.colors.outlineVariant),
),
child: AnimatedSwitcher(
key: const ValueKey('theme-preview-appearance-switcher'),
duration: reduceMotion ? Duration.zero : _appearanceMotionDuration,
switchInCurve: _appearanceMotionCurve,
switchOutCurve: Curves.easeInOutCubic,
layoutBuilder: (currentChild, previousChildren) => Stack(
alignment: Alignment.center,
children: [...previousChildren, ?currentChild],
),
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween(begin: 0.82, end: 1.0).animate(animation),
child: child,
),
),
child: Icon(
_icon,
key: ValueKey('theme-appearance-${mode.name}'),
size: 24,
color: context.colors.onSurface,
),
),
),
),
);
}
}
class _ThemeScrubber extends StatelessWidget {
const _ThemeScrubber({
required this.count,
required this.selected,
required this.animateChanges,
required this.onSelected,
});
final int count;
final int selected;
final bool animateChanges;
final ValueChanged<int> onSelected;
@override
Widget build(BuildContext context) {
void selectFromPosition(double dx, double width) {
if (width <= 0 || count <= 1) return;
final index = ((dx / width) * count).floor().clamp(0, count - 1);
onSelected(index);
}
return Semantics(
label: 'Theme ${selected + 1} of $count',
slider: true,
value: '${selected + 1}',
increasedValue: selected < count - 1 ? '${selected + 2}' : null,
decreasedValue: selected > 0 ? '$selected' : null,
onIncrease: selected < count - 1 ? () => onSelected(selected + 1) : null,
onDecrease: selected > 0 ? () => onSelected(selected - 1) : null,
child: SizedBox(
key: const ValueKey('theme-preview-scrubber'),
height: 54,
child: defaultTargetPlatform == TargetPlatform.iOS
? Center(
child: SizedBox(
width: _iosThemeScrubberWidth,
child: IosGlassThemePagination(
count: count,
selected: selected,
animateChanges: animateChanges,
onSelected: onSelected,
activeColor: context.colors.onSurface,
inactiveColor: context.colors.onSurfaceVariant.withValues(
alpha: 0.32,
),
),
),
)
: LayoutBuilder(
builder: (context, constraints) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (details) => selectFromPosition(
details.localPosition.dx,
constraints.maxWidth,
),
onHorizontalDragStart: (details) => selectFromPosition(
details.localPosition.dx,
constraints.maxWidth,
),
onHorizontalDragUpdate: (details) => selectFromPosition(
details.localPosition.dx,
constraints.maxWidth,
),
child: Center(
child: SizedBox(
width: _iosThemeScrubberWidth,
child: _WindowedThemePagination(
count: count,
selected: selected,
animateChanges: animateChanges,
),
),
),
),
),
),
);
}
}
class _WindowedThemePagination extends StatelessWidget {
const _WindowedThemePagination({
required this.count,
required this.selected,
required this.animateChanges,
});
static const _maximumVisibleDots = 7;
static const _dotSize = 6.0;
static const _selectedDotSize = 10.0;
static const _spacing = 6.0;
final int count;
final int selected;
final bool animateChanges;
@override
Widget build(BuildContext context) {
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final duration = reduceMotion || !animateChanges
? Duration.zero
: const Duration(milliseconds: 150);
final visibleCount = count.clamp(1, _maximumVisibleDots);
final maximumStart = (count - visibleCount).clamp(0, count);
final centerSlot = visibleCount ~/ 2;
final windowStart = (selected - centerSlot).clamp(0, maximumStart);
final windowEnd = windowStart + visibleCount - 1;
final hasEarlierDots = windowStart > 0;
final hasLaterDots = windowEnd < count - 1;
return Container(
height: 30,
padding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.full),
),
child: LayoutBuilder(
builder: (context, constraints) {
final pitch = _dotSize + _spacing;
final trackWidth =
visibleCount * _dotSize + (visibleCount - 1) * _spacing;
final trackOrigin = (constraints.maxWidth - trackWidth) / 2;
return ClipRect(
child: Stack(
children: [
for (var page = 0; page < count; page++)
_buildDot(
context: context,
page: page,
slot: page - windowStart,
visibleCount: visibleCount,
trackOrigin: trackOrigin,
pitch: pitch,
hasEarlierDots: hasEarlierDots,
hasLaterDots: hasLaterDots,
duration: duration,
),
],
),
);
},
),
);
}
Widget _buildDot({
required BuildContext context,
required int page,
required int slot,
required int visibleCount,
required double trackOrigin,
required double pitch,
required bool hasEarlierDots,
required bool hasLaterDots,
required Duration duration,
}) {
final isVisible = slot >= 0 && slot < visibleCount;
final diameter = page == selected
? _selectedDotSize
: (hasEarlierDots && slot == 0) ||
(hasLaterDots && slot == visibleCount - 1)
? 2.0
: (hasEarlierDots && slot == 1) ||
(hasLaterDots && slot == visibleCount - 2)
? 4.0
: _dotSize;
final centerX = trackOrigin + _dotSize / 2 + slot * pitch;
return AnimatedPositioned(
key: ValueKey('theme-pagination-dot-$page'),
duration: duration,
curve: Curves.easeInOutCubic,
left: centerX - diameter / 2,
top: (30 - diameter) / 2,
width: diameter,
height: diameter,
child: AnimatedOpacity(
duration: duration,
opacity: isVisible ? 1 : 0,
child: DecoratedBox(
decoration: BoxDecoration(
color: page == selected
? context.colors.onSurface
: context.colors.onSurfaceVariant.withValues(alpha: 0.32),
shape: BoxShape.circle,
),
),
),
);
}
}

View File

@@ -15,7 +15,10 @@ class Radii {
static const double lg = 10.0;
static const double md = 8.0;
static const double sm = 6.0;
static const double card = 12.0; // grouped settings cards
/// Shared strong radius for grouped rows, fields, and utility containers.
static const double container = 22.0;
static const double card = container; // Backwards-compatible card alias.
static const double popover = 20.0;
static const double dialog = 24.0; // desktop uses rounded-3xl for dialogs

View File

@@ -147,6 +147,11 @@ class CommunityThemeNotifier extends Notifier<CommunityThemePreference> {
);
}
/// Persists a complete theme-picker selection as one durable snapshot.
void setPreference(CommunityThemePreference preference) {
_save(preference);
}
void _save(CommunityThemePreference preference) {
if (preference == state) return;
state = preference;

View File

@@ -14,3 +14,4 @@ export 'theme_extensions.dart';
export 'theme_pairs.dart';
export 'theme_provider.dart';
export 'text_theme.dart' show bodyExtraSmallTextStyle;
export 'utility_surface_theme.dart';

View File

@@ -1,3 +1,4 @@
import 'buzz_theme.dart';
import 'theme_catalog.dart';
/// Light → dark theme counterparts, ported from the desktop app's `THEME_PAIRS`
@@ -59,8 +60,9 @@ class ThemeGroups {
ThemeGroups? _groups;
/// [themeCatalog] grouped for the appearance picker, each list sorted by
/// display name. Computed once — the catalog is a compile-time constant.
/// [themeCatalog] grouped for the appearance picker. Buzz is always first;
/// borrowed themes follow in display-name order. Computed once — the catalog
/// is a compile-time constant.
ThemeGroups themeGroups() {
final cached = _groups;
if (cached != null) return cached;
@@ -78,11 +80,16 @@ ThemeGroups themeGroups() {
if (themePairs.containsKey(theme.name)) paired.add(theme);
}
int byDisplayName(ThemeColors a, ThemeColors b) =>
a.displayName.compareTo(b.displayName);
paired.sort(byDisplayName);
light.sort(byDisplayName);
dark.sort(byDisplayName);
int byPickerOrder(ThemeColors a, ThemeColors b) {
final aIsBuzz = isBuzzTheme(a.name);
final bIsBuzz = isBuzzTheme(b.name);
if (aIsBuzz != bIsBuzz) return aIsBuzz ? -1 : 1;
return a.displayName.compareTo(b.displayName);
}
paired.sort(byPickerOrder);
light.sort(byPickerOrder);
dark.sort(byPickerOrder);
return _groups = ThemeGroups(paired: paired, light: light, dark: dark);
}

View File

@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'app_theme.dart';
/// Returns a theme with the page and container surface roles exchanged.
///
/// The marker makes the transform idempotent: a sheet opened from an already
/// transformed utility page keeps the same hierarchy instead of swapping the
/// two colors back again.
ThemeData utilitySurfaceThemeData(ThemeData theme) {
if (theme.extension<_UtilitySurfaceThemeMarker>() != null) return theme;
final colors = theme.colorScheme;
final pageSurface = colors.surfaceContainerHighest;
final containerSurface = colors.surface;
final containerShape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.container),
);
return theme.copyWith(
colorScheme: colors.copyWith(
surface: pageSurface,
surfaceContainerHighest: containerSurface,
),
scaffoldBackgroundColor: pageSurface,
cardTheme: theme.cardTheme.copyWith(
color: containerSurface,
shape: containerShape,
),
inputDecorationTheme: theme.inputDecorationTheme.copyWith(
border: _withUtilityContainerRadius(theme.inputDecorationTheme.border),
enabledBorder: _withUtilityContainerRadius(
theme.inputDecorationTheme.enabledBorder,
),
focusedBorder: _withUtilityContainerRadius(
theme.inputDecorationTheme.focusedBorder,
),
errorBorder: _withUtilityContainerRadius(
theme.inputDecorationTheme.errorBorder,
),
focusedErrorBorder: _withUtilityContainerRadius(
theme.inputDecorationTheme.focusedErrorBorder,
),
disabledBorder: _withUtilityContainerRadius(
theme.inputDecorationTheme.disabledBorder,
),
),
bottomSheetTheme: theme.bottomSheetTheme.copyWith(
backgroundColor: pageSurface,
modalBackgroundColor: pageSurface,
),
extensions: [
...theme.extensions.values,
const _UtilitySurfaceThemeMarker(),
],
);
}
InputBorder? _withUtilityContainerRadius(InputBorder? border) {
if (border is! OutlineInputBorder) return border;
return border.copyWith(borderRadius: BorderRadius.circular(Radii.container));
}
@immutable
class _UtilitySurfaceThemeMarker
extends ThemeExtension<_UtilitySurfaceThemeMarker> {
const _UtilitySurfaceThemeMarker();
@override
_UtilitySurfaceThemeMarker copyWith() => this;
@override
_UtilitySurfaceThemeMarker lerp(
covariant ThemeExtension<_UtilitySurfaceThemeMarker>? other,
double t,
) => this;
}

View File

@@ -87,7 +87,7 @@ class AppListCard extends StatelessWidget {
// does. The dividers carry the group structure, so the fill only has
// to separate the card from the page.
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.card),
borderRadius: BorderRadius.circular(Radii.container),
// Keeps row ripples inside the rounded corners.
clipBehavior: Clip.antiAlias,
child: AppListInset(

View File

@@ -0,0 +1,5 @@
/// Shared geometry for page and sheet navigation chrome.
const double buzzNavigationRowHeight = 56;
/// Touch-target size for navigation actions such as Back, Close, and Set.
const double buzzNavigationActionSize = 44;

View File

@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../theme/theme.dart';
import 'buzz_navigation_metrics.dart';
import 'ios_glass_navigation_button.dart';
/// A titled sheet header with balanced actions and an exactly centered title.
@@ -34,7 +35,7 @@ class BuzzSheetHeader extends StatelessWidget {
bottom: Grid.xs,
),
child: SizedBox(
height: 56,
height: buzzNavigationRowHeight,
child: Stack(
alignment: Alignment.topCenter,
children: [
@@ -87,14 +88,14 @@ class _SheetCloseButton extends StatelessWidget {
icon: IosGlassNavigationIcon.close,
semanticLabel: 'Close sheet',
onPressed: closeSheet,
width: 44,
height: 44,
width: buzzNavigationActionSize,
height: buzzNavigationActionSize,
foregroundColor: context.colors.primary,
);
}
return SizedBox.square(
dimension: 44,
dimension: buzzNavigationActionSize,
child: IconButton(
tooltip: 'Close sheet',
onPressed: closeSheet,

View File

@@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../theme/theme.dart';
import 'buzz_sheet_header.dart';
import 'concentric_sheet_surface.dart';
/// Shared solid sheet surface with a centered navigation row.
class BuzzTitledSheetLayout extends StatelessWidget {
class BuzzTitledSheetLayout extends HookWidget {
const BuzzTitledSheetLayout({
super.key,
required this.title,
@@ -27,9 +28,33 @@ class BuzzTitledSheetLayout extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isScrolledUnder = useState(false);
final pendingScrolledUnder = useRef<bool?>(null);
final scrollUpdateScheduled = useRef(false);
final color = surfaceColor ?? context.colors.surface;
final paintsSurface = !ConcentricSheetSurface.providesSurfaceOf(context);
void updateScrollUnder(bool next) {
if (scrollUpdateScheduled.value) {
pendingScrolledUnder.value =
(pendingScrolledUnder.value ?? false) || next;
return;
}
pendingScrolledUnder.value = next;
scrollUpdateScheduled.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
scrollUpdateScheduled.value = false;
final pending = pendingScrolledUnder.value;
pendingScrolledUnder.value = null;
if (!context.mounted ||
pending == null ||
pending == isScrolledUnder.value) {
return;
}
isScrolledUnder.value = pending;
});
}
final sheet = SizedBox(
width: double.infinity,
child: ColoredBox(
@@ -38,14 +63,41 @@ class BuzzTitledSheetLayout extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
BuzzSheetHeader(
title: title,
titleKey: titleKey,
leading: leading,
trailing: trailing,
showDragHandle: showDragHandle,
DecoratedBox(
key: const ValueKey('buzz-sheet-scroll-divider'),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: isScrolledUnder.value
? navigationDivider(context, 0.05)
: Colors.transparent,
),
),
),
child: BuzzSheetHeader(
title: title,
titleKey: titleKey,
leading: leading,
trailing: trailing,
showDragHandle: showDragHandle,
),
),
Flexible(
child: NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification.depth != 0 ||
notification.metrics.axis != Axis.vertical ||
(notification is! ScrollUpdateNotification &&
notification is! OverscrollNotification)) {
return false;
}
final next = notification.metrics.extentBefore > 0.5;
if (next != isScrolledUnder.value) updateScrollUnder(next);
return false;
},
child: child,
),
),
Flexible(child: child),
],
),
),

View File

@@ -0,0 +1,131 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme/theme.dart';
import 'ios_glass_navigation_button.dart';
/// A labelled circular mode option matching the profile editor controls.
class EditorOptionButton extends StatelessWidget {
const EditorOptionButton({
super.key,
required this.icon,
this.iosIcon,
required this.label,
required this.selected,
required this.onTap,
this.labelMaxWidth,
});
final IconData icon;
final IosGlassNavigationIcon? iosIcon;
final String label;
final bool selected;
final VoidCallback? onTap;
final double? labelMaxWidth;
@override
Widget build(BuildContext context) {
final handleTap = onTap == null
? null
: () {
unawaited(HapticFeedback.selectionClick());
onTap!();
};
Widget labelWidget() {
final style = context.textTheme.labelSmall?.copyWith(
color: selected
? context.colors.onSurface
: context.colors.onSurfaceVariant,
);
final width = labelMaxWidth;
if (width == null) {
return Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style,
);
}
return SizedBox(
height: 20,
child: OverflowBox(
maxWidth: width,
maxHeight: 20,
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style,
),
),
);
}
if (defaultTargetPlatform == TargetPlatform.iOS && iosIcon != null) {
return Column(
children: [
IosGlassNavigationButton(
icon: iosIcon!,
semanticLabel: label,
onPressed: handleTap,
width: 64,
height: 64,
controlSize: 64,
foregroundColor: selected
? context.colors.primary
: context.colors.onSurface,
isSelected: selected,
),
const SizedBox(height: Grid.half),
ExcludeSemantics(child: labelWidget()),
],
);
}
return Semantics(
label: label,
button: true,
selected: selected,
enabled: handleTap != null,
onTap: handleTap,
child: ExcludeSemantics(
child: InkResponse(
radius: 34,
onTap: handleTap,
child: SizedBox(
width: double.infinity,
child: Column(
children: [
AnimatedContainer(
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic,
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected
? context.colors.onSurface
: context.colors.surfaceContainerHighest,
),
child: Icon(
icon,
size: 26,
color: selected
? context.colors.surface
: context.colors.onSurface,
),
),
const SizedBox(height: Grid.half),
labelWidget(),
],
),
),
),
),
);
}
}

View File

@@ -4,11 +4,13 @@ import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../theme/theme.dart';
import 'buzz_navigation_metrics.dart';
import 'directional_transition_scope.dart';
import 'frosted_scroll_under_scope.dart';
import 'ios_glass_navigation_button.dart';
/// Minimum height of the frosted app bar content area below the safe area.
const _kBarContentMinHeight = Grid.xxs + 32 + Grid.xxs; // 48
const _kBarContentMinHeight = buzzNavigationRowHeight;
const _kBottomBorderWidth = 1.0;
TextStyle _effectiveTitleStyle(BuildContext context, TextStyle? titleStyle) {
@@ -160,7 +162,7 @@ class FrostedAppBar extends StatelessWidget {
this.frostedSurfaceOpacity = 0.5,
this.frostedBlurSigma = 20,
this.showBottomDivider = true,
this.bottomDividerOpacity = 0.07,
this.bottomDividerOpacity = 0.05,
}) : assert(bottom == null || bottomHeight > 0),
assert(bottomOverlap >= 0),
assert(bottom != null || bottomOverlap == 0),
@@ -170,6 +172,9 @@ class FrostedAppBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final topPadding = MediaQuery.paddingOf(context).top;
final scrollUnder = FrostedScrollUnderScope.maybeOf(context);
final paintsBottomDivider =
showBottomDivider && (scrollUnder?.isScrolledUnder ?? true);
final canPop = Navigator.canPop(context);
final effectiveTitleStyle = _effectiveTitleStyle(context, titleStyle);
final barContentHeight = _barContentHeight(
@@ -296,7 +301,9 @@ class FrostedAppBar extends StatelessWidget {
border: showBottomDivider
? Border(
bottom: BorderSide(
color: navigationDivider(context, bottomDividerOpacity),
color: paintsBottomDivider
? navigationDivider(context, bottomDividerOpacity)
: Colors.transparent,
width: _kBottomBorderWidth,
),
)

View File

@@ -1,14 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../theme/theme.dart';
import 'directional_transition_scope.dart';
import 'frosted_app_bar.dart';
import 'frosted_scroll_under_scope.dart';
/// A convenience [Scaffold] that overlays a [FrostedAppBar] on top of its body.
///
/// The body is rendered full-bleed inside a [Stack] with the frosted app bar
/// floating above it. The body is responsible for adding its own top spacing
/// using [frostedAppBarHeight] so content starts below the bar.
class FrostedScaffold extends StatelessWidget {
class FrostedScaffold extends HookWidget {
/// The frosted app bar displayed at the top of the screen.
final FrostedAppBar appBar;
@@ -29,6 +32,10 @@ class FrostedScaffold extends StatelessWidget {
/// A fixed gradient painted behind the app bar and scrolling body.
final Gradient? backgroundGradient;
/// Whether this settings-style page should invert the canvas and container
/// surface roles.
final bool useUtilitySurfaceTheme;
const FrostedScaffold({
super.key,
required this.appBar,
@@ -37,19 +44,67 @@ class FrostedScaffold extends StatelessWidget {
this.resizeToAvoidBottomInset,
this.backgroundColor,
this.backgroundGradient,
this.useUtilitySurfaceTheme = false,
});
@override
Widget build(BuildContext context) {
return Scaffold(
final isScrolledUnder = useState(false);
final pendingScrolledUnder = useRef<bool?>(null);
final scrollUpdateScheduled = useRef(false);
void updateScrollUnder(bool next) {
if (scrollUpdateScheduled.value) {
pendingScrolledUnder.value =
(pendingScrolledUnder.value ?? false) || next;
return;
}
pendingScrolledUnder.value = next;
scrollUpdateScheduled.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
scrollUpdateScheduled.value = false;
final pending = pendingScrolledUnder.value;
pendingScrolledUnder.value = null;
if (!context.mounted ||
pending == null ||
pending == isScrolledUnder.value) {
return;
}
isScrolledUnder.value = pending;
});
}
final observedBody = NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification.depth != 0 ||
notification.metrics.axis != Axis.vertical ||
(notification is! ScrollUpdateNotification &&
notification is! OverscrollNotification)) {
return false;
}
final next = notification.metrics.extentBefore > 0.5;
if (next != isScrolledUnder.value) updateScrollUnder(next);
return false;
},
child: body,
);
final scaffold = Scaffold(
backgroundColor: backgroundColor,
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
floatingActionButton: floatingActionButton,
body: Stack(children: _stackChildren()),
body: FrostedScrollUnderScope(
isScrolledUnder: isScrolledUnder.value,
child: Stack(children: _stackChildren(observedBody)),
),
);
if (!useUtilitySurfaceTheme) return scaffold;
return Theme(
data: utilitySurfaceThemeData(Theme.of(context)),
child: scaffold,
);
}
List<Widget> _stackChildren() {
List<Widget> _stackChildren(Widget observedBody) {
final backdrop = backgroundGradient == null
? const <Widget>[]
: [
@@ -62,7 +117,7 @@ class FrostedScaffold extends StatelessWidget {
'frosted-scaffold-body-transition-transform',
),
opacityKey: const ValueKey('frosted-scaffold-body-transition-opacity'),
child: body,
child: observedBody,
);
// The bar must be painted after the scrollable sheet: [BackdropFilter]
// only samples pixels that were already painted behind it. This is the

View File

@@ -0,0 +1,19 @@
import 'package:flutter/widgets.dart';
/// Supplies whether vertical content has moved beneath a frosted app bar.
class FrostedScrollUnderScope extends InheritedWidget {
const FrostedScrollUnderScope({
super.key,
required this.isScrolledUnder,
required super.child,
});
final bool isScrolledUnder;
static FrostedScrollUnderScope? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<FrostedScrollUnderScope>();
@override
bool updateShouldNotify(FrostedScrollUnderScope oldWidget) =>
isScrolledUnder != oldWidget.isScrolledUnder;
}

View File

@@ -15,11 +15,16 @@ enum IosGlassNavigationIcon {
camera,
photoLibrary,
palette,
droplet,
emoji,
person,
frame,
rotateCamera,
shutter,
colorSwatch,
sun,
moon,
systemAppearance,
}
/// Leading width used by iOS channel-style headers.
@@ -48,6 +53,7 @@ class IosGlassNavigationButton extends HookWidget {
this.fillWidth = false,
this.buttonCenterX,
this.foregroundColor,
this.swatchColor,
this.isBusy = false,
this.isSelected = false,
this.nativeViewSuppressed,
@@ -85,6 +91,9 @@ class IosGlassNavigationButton extends HookWidget {
/// Optional foreground tint for the native control and Flutter fallback.
final Color? foregroundColor;
/// Optional inset color swatch used by [IosGlassNavigationIcon.colorSwatch].
final Color? swatchColor;
/// Whether the native control presents its busy state.
final bool isBusy;
@@ -102,6 +111,7 @@ class IosGlassNavigationButton extends HookWidget {
final brightness = context.theme.brightness.name;
final effectiveForeground = foregroundColor ?? context.colors.primary;
final foregroundValue = effectiveForeground.toARGB32();
final swatchColorValue = swatchColor?.toARGB32();
final enabled = onPressed != null;
useEffect(() {
@@ -126,6 +136,7 @@ class IosGlassNavigationButton extends HookWidget {
'enabled': enabled,
'busy': isBusy,
'selected': isSelected,
'swatchColor': ?swatchColorValue,
}),
);
}
@@ -138,6 +149,7 @@ class IosGlassNavigationButton extends HookWidget {
enabled,
isBusy,
isSelected,
swatchColorValue,
],
);
@@ -193,6 +205,16 @@ class IosGlassNavigationButton extends HookWidget {
),
),
)
: icon == IosGlassNavigationIcon.colorSwatch
? Padding(
padding: const EdgeInsets.all(4),
child: DecoratedBox(
decoration: BoxDecoration(
color: swatchColor,
shape: BoxShape.circle,
),
),
)
: label != null
? Text(
label!,
@@ -214,6 +236,8 @@ class IosGlassNavigationButton extends HookWidget {
Icons.photo_library_rounded,
IosGlassNavigationIcon.palette =>
Icons.palette_rounded,
IosGlassNavigationIcon.droplet =>
Icons.water_drop_rounded,
IosGlassNavigationIcon.emoji =>
Icons.emoji_emotions_rounded,
IosGlassNavigationIcon.person =>
@@ -223,11 +247,21 @@ class IosGlassNavigationButton extends HookWidget {
IosGlassNavigationIcon.rotateCamera =>
Icons.cameraswitch_rounded,
IosGlassNavigationIcon.shutter => Icons.circle,
IosGlassNavigationIcon.colorSwatch =>
Icons.circle,
IosGlassNavigationIcon.sun =>
Icons.light_mode_rounded,
IosGlassNavigationIcon.moon =>
Icons.dark_mode_rounded,
IosGlassNavigationIcon.systemAppearance =>
Icons.brightness_auto_rounded,
},
size: icon == IosGlassNavigationIcon.shutter
? controlSize * 0.72
: 22,
color: effectiveForeground,
color: icon == IosGlassNavigationIcon.colorSwatch
? swatchColor
: effectiveForeground,
),
),
),
@@ -250,6 +284,7 @@ class IosGlassNavigationButton extends HookWidget {
'buttonCenterX': buttonCenterX ?? width / 2,
'hitTargetWidth': width,
'hitTargetHeight': height,
'swatchColor': ?swatchColorValue,
};
if (label != null) creationParams['label'] = label!;
return UiKitView(

View File

@@ -0,0 +1,96 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show PlatformViewHitTestBehavior;
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// Native iOS liquid-glass pagination that supports tapping and scrubbing.
class IosGlassThemePagination extends HookWidget {
/// Creates a native theme-pagination control.
const IosGlassThemePagination({
super.key,
required this.count,
required this.selected,
required this.animateChanges,
required this.onSelected,
required this.activeColor,
required this.inactiveColor,
});
static const viewType = 'buzz/theme_pagination_glass';
final int count;
final int selected;
final bool animateChanges;
final ValueChanged<int> onSelected;
final Color activeColor;
final Color inactiveColor;
@override
Widget build(BuildContext context) {
assert(defaultTargetPlatform == TargetPlatform.iOS);
final nativeChannel = useState<MethodChannel?>(null);
final onSelectedRef = useRef(onSelected)..value = onSelected;
final brightness = Theme.of(context).brightness.name;
final activeColorValue = activeColor.toARGB32();
final inactiveColorValue = inactiveColor.toARGB32();
useEffect(() {
final channel = nativeChannel.value;
if (channel == null) return null;
channel.setMethodCallHandler((call) async {
if (call.method == 'selected' && call.arguments is int) {
onSelectedRef.value(call.arguments as int);
}
});
return () => channel.setMethodCallHandler(null);
}, [nativeChannel.value]);
useEffect(
() {
final channel = nativeChannel.value;
if (channel != null) {
unawaited(
channel.invokeMethod<void>('setState', <String, Object>{
'count': count,
'selected': selected,
'animateChanges': animateChanges,
'brightness': brightness,
'activeColor': activeColorValue,
'inactiveColor': inactiveColorValue,
}),
);
}
return null;
},
[
nativeChannel.value,
count,
selected,
animateChanges,
brightness,
activeColorValue,
inactiveColorValue,
],
);
return UiKitView(
viewType: viewType,
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
creationParams: <String, Object>{
'count': count,
'selected': selected,
'animateChanges': animateChanges,
'brightness': brightness,
'activeColor': activeColorValue,
'inactiveColor': inactiveColorValue,
},
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated: (viewId) {
nativeChannel.value = MethodChannel('$viewType/$viewId');
},
);
}
}

View File

@@ -50,29 +50,38 @@ Future<T?> showBuzzModalBottomSheet<T>({
}) {
final isIos = defaultTargetPlatform == TargetPlatform.iOS;
final theme = Theme.of(context);
// Explicitly colored sheets (for example the dark Huddle drawer) own their
// complete surface treatment. Standard utility sheets use the quieter page
// canvas and promote their controls onto the base app surface.
final sheetTheme = backgroundColor == null
? utilitySurfaceThemeData(theme)
: theme;
final surfaceColor =
backgroundColor ??
theme.bottomSheetTheme.modalBackgroundColor ??
theme.bottomSheetTheme.backgroundColor ??
context.colors.surface;
sheetTheme.bottomSheetTheme.modalBackgroundColor ??
sheetTheme.bottomSheetTheme.backgroundColor ??
sheetTheme.colorScheme.surface;
final reduceMotion = MediaQuery.disableAnimationsOf(context);
return showModalBottomSheet<T>(
context: context,
builder: (sheetContext) => ConcentricSheetSurface(
enabled: isIos,
color: surfaceColor,
child: _SheetContent(
title: title,
showCloseButton: showCloseButton,
showDragHandle: showDragHandle == true,
surfaceColor: surfaceColor,
child: builder(sheetContext),
builder: (sheetContext) => Theme(
data: sheetTheme,
child: Builder(
builder: (themedContext) => ConcentricSheetSurface(
enabled: isIos,
color: surfaceColor,
child: _SheetContent(
title: title,
showCloseButton: showCloseButton,
showDragHandle: showDragHandle == true,
surfaceColor: surfaceColor,
child: builder(themedContext),
),
),
),
),
backgroundColor: isIos || title != null
? Colors.transparent
: backgroundColor,
backgroundColor: isIos || title != null ? Colors.transparent : surfaceColor,
barrierLabel: barrierLabel,
elevation: elevation,
shape: shape,

View File

@@ -279,9 +279,7 @@ void main() {
expect(border.borderRadius, BorderRadius.circular(Radii.full));
});
testWidgets('uses the shared sheet surface instead of a picker override', (
tester,
) async {
testWidgets('uses the transformed shared sheet surface', (tester) async {
final prefs = await _prefs();
final theme = AppTheme.light().copyWith(
colorScheme: lightColorScheme.copyWith(
@@ -326,11 +324,11 @@ void main() {
.map((element) => element.widget as Material);
expect(
ancestorMaterials.map((material) => material.color),
contains(Colors.green),
contains(Colors.grey),
);
expect(
ancestorMaterials.map((material) => material.color),
isNot(contains(Colors.grey)),
isNot(contains(Colors.green)),
);
});

View File

@@ -234,6 +234,10 @@ void main() {
'placeholder': 'Display name',
'multiline': false,
'brightness': 'dark',
'pageBackgroundArgb': AppTheme.dark().colorScheme.surfaceContainerHighest
.toARGB32(),
'containerBackgroundArgb': AppTheme.dark().colorScheme.surface.toARGB32(),
'containerCornerRadius': Radii.container,
'allowUnchangedSubmission': false,
});
expect(notifier.savedDisplayNames, ['Alice Native']);
@@ -243,6 +247,8 @@ void main() {
testWidgets('shows profile fields and saves each one from a sheet', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final notifier = _FakeProfileNotifier();
await tester.pumpWidget(
WidgetHelpers.testable(
@@ -288,6 +294,7 @@ void main() {
expect(notifier.savedDescriptions, [
'Making collaboration feel effortless.',
]);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('uses an icon-free grouped card with inset dividers', (
@@ -344,6 +351,19 @@ void main() {
expect(find.text('Animated'), findsOneWidget);
expect(find.text('Photo'), findsNothing);
final modeControl = find.byKey(const ValueKey('avatar-mode-control'));
final modeIndicator = find.byKey(const ValueKey('avatar-mode-indicator'));
final controlRect = tester.getRect(modeControl);
final indicatorRect = tester.getRect(modeIndicator);
expect(indicatorRect.left - controlRect.left, Grid.quarter);
expect(indicatorRect.top - controlRect.top, Grid.quarter);
expect(controlRect.bottom - indicatorRect.bottom, Grid.quarter);
expect(
controlRect.width - Grid.quarter * 2,
closeTo(indicatorRect.width * 3, 0.01),
);
final indicatorDecoration =
tester.widget<DecoratedBox>(modeIndicator).decoration as BoxDecoration;
expect(indicatorDecoration.boxShadow, isNull);
final preview = find.descendant(
of: find.byKey(const ValueKey('avatar-editor-content')),
matching: find.byType(AvatarImage),

View File

@@ -116,6 +116,44 @@ void main() {
expect(opened, ['name', 'description']);
});
testWidgets('places Theme second in the Community section', (tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
currentCommunityRoleProvider.overrideWithValue(
const AsyncData<CommunityMemberRole?>(CommunityMemberRole.admin),
),
],
child: MaterialApp(
theme: AppTheme.light(),
home: SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Theme'), findsOneWidget);
expect(
tester.getTopLeft(find.text('Theme')).dy,
greaterThan(tester.getTopLeft(find.text('Invite to community')).dy),
);
expect(find.byKey(const ValueKey('community-theme-row')), findsOneWidget);
expect(find.text('Appearance'), findsNothing);
expect(find.text('Style · This community'), findsNothing);
await tester.tap(find.byKey(const ValueKey('community-theme-row')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('theme-preview-pages')), findsOneWidget);
});
testWidgets('uses the native glass close control on iOS', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);

View File

@@ -1,10 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:buzz/features/settings/accent_picker_page.dart';
import 'package:buzz/features/settings/theme_picker_page.dart';
import 'package:buzz/features/settings/settings_page.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../helpers/widget_helpers.dart';
@@ -14,227 +13,755 @@ Future<SharedPreferences> _prefs(Map<String, Object> initial) async {
return SharedPreferences.getInstance();
}
Future<void> _pumpPicker(
WidgetTester tester,
Widget page, {
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: page,
overrides: [savedPrefsProvider.overrideWithValue(instance)],
child: MediaQuery(
data: MediaQueryData(disableAnimations: reduceMotion),
child: const ThemePickerPage(),
),
overrides: [
savedPrefsProvider.overrideWithValue(instance),
if (themeNotifier != null)
communityThemeProvider.overrideWith(themeNotifier),
],
),
);
await tester.pumpAndSettle();
return instance;
}
Future<void> _search(WidgetTester tester, String query) async {
await tester.enterText(find.byType(TextField), query);
Future<void> _swipeToNextTheme(WidgetTester tester) async {
await tester.fling(
find.byKey(const ValueKey('theme-preview-pages')),
const Offset(-600, 0),
1200,
);
await tester.pumpAndSettle();
}
void main() {
group('ThemePickerPage', () {
testWidgets('system mode offers pairs under their stripped label', (
testWidgets(
'is the direct preview page with the name inside its container',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpPicker(tester);
expect(
find.byKey(const ValueKey('theme-preview-pages')),
findsOneWidget,
);
expect(find.byKey(const ValueKey('theme-preview-set')), findsOneWidget);
expect(
find.byKey(const ValueKey('theme-preview-close')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('theme-selection-grid')),
findsNothing,
);
expect(
find.byKey(const ValueKey('theme-preview-name-buzz')),
findsOneWidget,
);
final nameBounds = tester.getRect(
find.byKey(const ValueKey('theme-preview-name-buzz')),
);
final previewBounds = tester.getRect(
find.byKey(const ValueKey('theme-device-pair-preview-buzz')),
);
expect(nameBounds.bottom, lessThan(previewBounds.bottom));
expect(previewBounds.top - nameBounds.bottom, closeTo(Grid.md, 1));
expect(
tester
.widget<FittedBox>(
find.byKey(const ValueKey('theme-full-preview-buzz')),
)
.alignment,
Alignment.center,
);
expect(
tester
.getSize(find.byKey(const ValueKey('theme-preview-close')))
.height,
44,
);
expect(
tester
.getSize(find.byKey(const ValueKey('theme-preview-set')))
.height,
44,
);
},
);
testWidgets('keeps the theme name inside the card in landscape', (
tester,
) async {
await _pumpPicker(tester, const ThemePickerPage());
tester.view.physicalSize = const Size(844, 390);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _search(tester, 'github');
await _pumpPicker(tester);
// Paired labels drop the brightness token — one row stands for both halves.
expect(find.text('Github'), findsOneWidget);
expect(find.text('Github Default'), findsOneWidget);
expect(find.text('Github Light'), findsNothing);
expect(find.text('Github Dark'), findsNothing);
});
testWidgets('system mode hides themes with no counterpart', (tester) async {
await _pumpPicker(tester, const ThemePickerPage());
// 'snazzy-light' is light but unpaired, so it cannot follow the OS.
await _search(tester, 'snazzy');
expect(find.text('No themes found'), findsOneWidget);
});
testWidgets('system mode normalizes a stored unpaired theme', (
tester,
) async {
final instance = await _prefs({'buzz_color_scheme': 'snazzy-light'});
await tester.pumpWidget(
WidgetHelpers.testable(
child: const ThemePickerPage(),
overrides: [savedPrefsProvider.overrideWithValue(instance)],
),
final nameBounds = tester.getRect(
find.byKey(const ValueKey('theme-preview-name-buzz')),
);
final cardBounds = tester.getRect(
find.byKey(const ValueKey('theme-preview-page-buzz')),
);
expect(cardBounds.contains(nameBounds.topLeft), isTrue);
expect(cardBounds.contains(nameBounds.bottomRight), isTrue);
});
testWidgets(
'keeps accurate Home and Chat frames in one preview container',
(tester) async {
await _pumpPicker(tester);
expect(
find.byKey(const ValueKey('theme-preview-surface')),
findsWidgets,
);
expect(
find.byKey(const ValueKey('theme-chat-timeline-message-1')),
findsWidgets,
);
expect(
find.byKey(const ValueKey('theme-preview-status-time')),
findsNothing,
);
final chatFrame = tester.widget<Container>(
find
.descendant(
of: find.byKey(const ValueKey('theme-full-chat-buzz')),
matching: find.byType(Container),
)
.first,
);
final decoration = chatFrame.decoration as BoxDecoration;
final border = decoration.border! as Border;
expect(chatFrame.clipBehavior, Clip.none);
expect(border.top.strokeAlign, BorderSide.strokeAlignOutside);
},
);
testWidgets('exposes only image labels for decorative previews', (
tester,
) async {
final semantics = tester.ensureSemantics();
try {
await _pumpPicker(tester);
expect(find.bySemanticsLabel(RegExp('Theme preview')), findsNothing);
expect(find.bySemanticsLabel(RegExp('Home preview')), findsWidgets);
expect(find.bySemanticsLabel(RegExp('Chat preview')), findsWidgets);
expect(find.bySemanticsLabel(RegExp('Community')), findsNothing);
} finally {
semantics.dispose();
}
});
testWidgets('theme name trails the preview during a swipe', (tester) async {
await _pumpPicker(tester);
final pages = find.byKey(const ValueKey('theme-preview-pages'));
final gesture = await tester.startGesture(tester.getCenter(pages));
await gesture.moveBy(const Offset(-120, 0));
await tester.pump();
final titleMotion = tester.widget<Transform>(
find.byKey(const ValueKey('theme-preview-name-motion-buzz')),
);
expect(titleMotion.transform.getTranslation().x, lessThan(0));
await gesture.up();
await tester.pumpAndSettle();
});
testWidgets('theme name does not trail with Reduce Motion', (tester) async {
await _pumpPicker(tester, reduceMotion: true);
final pages = find.byKey(const ValueKey('theme-preview-pages'));
final gesture = await tester.startGesture(tester.getCenter(pages));
await gesture.moveBy(const Offset(-120, 0));
await tester.pump();
final titleMotion = tester.widget<Transform>(
find.byKey(const ValueKey('theme-preview-name-motion-buzz')),
);
expect(titleMotion.transform.getTranslation().x, 0);
await gesture.up();
await tester.pumpAndSettle();
});
testWidgets('matches the updated Figma Home skeleton', (tester) async {
await _pumpPicker(tester);
const rowTops = [
116.0,
152.0,
188.0,
224.0,
266.0,
302.0,
338.0,
374.0,
416.0,
452.0,
488.0,
524.0,
];
const widths = [69.0, 69.0, 107.0, 91.0];
for (var index = 0; index < rowTops.length; index++) {
final isSection = index % 4 == 0;
final row = find
.byKey(
isSection
? ValueKey('theme-preview-section-${index ~/ 4}')
: ValueKey('theme-preview-channel-$index'),
)
.first;
final capsule = find
.byKey(
isSection
? ValueKey('theme-preview-section-${index ~/ 4}-capsule')
: ValueKey('theme-preview-channel-$index-capsule'),
)
.first;
expect(row, findsOneWidget);
final position = tester.widget<Positioned>(
find.ancestor(of: row, matching: find.byType(Positioned)).first,
);
expect(position.top, rowTops[index]);
expect(position.left, isSection ? 22 : 38);
expect(tester.getSize(capsule).height, 11);
expect(tester.getSize(capsule).width, widths[index % widths.length]);
final capsuleWidget = tester.widget<Container>(capsule);
final capsuleContext = tester.element(capsule);
expect(
(capsuleWidget.decoration! as BoxDecoration).color,
isSection
? navigationSectionForeground(
capsuleContext,
).withValues(alpha: 0.52)
: navigationPrimaryForeground(
capsuleContext,
).withValues(alpha: 0.38),
);
}
expect(
find.byKey(const ValueKey('theme-preview-section-3')),
findsNothing,
);
final identity = find
.byKey(const ValueKey('theme-preview-community-identity'))
.first;
final home = tester.getRect(
find.byKey(const ValueKey('theme-full-home-buzz')),
);
final identityBounds = tester.getRect(identity);
expect(
(identityBounds.top - home.top) / home.height * 852,
closeTo(38, 4),
);
expect(
find.byKey(const ValueKey('theme-preview-community-icon')),
findsWidgets,
);
expect(
find.byKey(const ValueKey('theme-preview-community-name')),
findsWidgets,
);
});
testWidgets('keeps the chat header left aligned and accents only Send', (
tester,
) async {
await _pumpPicker(tester);
expect(
instance.getString('buzz_color_scheme'),
themeGroups().paired.first.name,
find.byKey(const ValueKey('theme-chat-header-avatar')),
findsNothing,
);
final title = tester.getRect(
find.byKey(const ValueKey('theme-chat-header-title')).first,
);
final chatFrame = tester.getRect(
find.byKey(const ValueKey('theme-full-chat-buzz')),
);
expect(
(title.left - chatFrame.left) / chatFrame.width * 393,
closeTo(70, 4),
);
expect(title.center.dx, lessThan(chatFrame.center.dx));
final send = tester.widget<DecoratedBox>(
find
.descendant(
of: find.byKey(const ValueKey('theme-chat-send-action')).first,
matching: find.byType(DecoratedBox),
)
.first,
);
final sendDecoration = send.decoration as BoxDecoration;
final previewContext = tester.element(
find.byKey(const ValueKey('theme-chat-send-action')).first,
);
expect(
sendDecoration.color,
Theme.of(previewContext).colorScheme.primary,
);
});
testWidgets('light mode lists light themes by their full name', (
tester,
) async {
await _pumpPicker(
tester,
const ThemePickerPage(),
prefs: {'buzz_theme_mode': 'light'},
);
testWidgets('uses native liquid-glass pagination on iOS', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await _search(tester, 'github');
await _pumpPicker(tester);
expect(find.text('Github Light'), findsOneWidget);
expect(find.text('Github Light Default'), findsOneWidget);
expect(find.text('Github Dark'), findsNothing);
});
testWidgets('light mode includes unpaired light themes', (tester) async {
await _pumpPicker(
tester,
const ThemePickerPage(),
prefs: {'buzz_theme_mode': 'light'},
);
await _search(tester, 'snazzy');
expect(find.text('Snazzy Light'), findsOneWidget);
});
testWidgets('dark mode lists dark themes only', (tester) async {
await _pumpPicker(
tester,
const ThemePickerPage(),
prefs: {'buzz_theme_mode': 'dark'},
);
await _search(tester, 'github');
expect(find.text('Github Dark'), findsOneWidget);
expect(find.text('Github Light'), findsNothing);
});
testWidgets('checks the row matching the stored selection', (tester) async {
await _pumpPicker(
tester,
const ThemePickerPage(),
prefs: {'buzz_theme_mode': 'light', 'buzz_color_scheme': 'nord'},
);
await _search(tester, 'nord');
// 'nord' is dark, so Light mode renders its light fallback instead — the
// checkmark must follow what is applied, not the raw stored value.
expect(find.byIcon(LucideIcons.check), findsNothing);
});
testWidgets('either half of a pair checks the same system row', (
tester,
) async {
await _pumpPicker(
tester,
const ThemePickerPage(),
prefs: {'buzz_color_scheme': 'github-dark'},
);
await _search(tester, 'github');
final checked = tester
.widgetList<ListTile>(find.byType(ListTile))
.where((tile) => tile.trailing != null);
expect(checked, hasLength(1));
expect(find.text('Github'), findsOneWidget);
});
testWidgets('tapping a theme persists the selection', (tester) async {
final instance = await _prefs({'buzz_theme_mode': 'dark'});
await tester.pumpWidget(
WidgetHelpers.testable(
child: const ThemePickerPage(),
overrides: [savedPrefsProvider.overrideWithValue(instance)],
final nativePagination = tester.widget<UiKitView>(
find.byWidgetPredicate(
(widget) =>
widget is UiKitView &&
widget.viewType == 'buzz/theme_pagination_glass',
),
);
await tester.pumpAndSettle();
await _search(tester, 'nord');
await tester.tap(find.text('Nord'));
await tester.pumpAndSettle();
expect(instance.getString('buzz_color_scheme'), 'nord');
});
});
group('Buzz accent behavior', () {
testWidgets('settings hides accent navigation for Buzz', (tester) async {
await _pumpPicker(
tester,
SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
prefs: {'buzz_color_scheme': 'buzz', 'buzz_accent_color': 4},
final params = nativePagination.creationParams! as Map<String, Object>;
expect(params['count'], greaterThan(1));
expect(params['selected'], 0);
expect(params['animateChanges'], isTrue);
expect(
tester
.getSize(
find.byWidgetPredicate(
(widget) =>
widget is UiKitView &&
widget.viewType == 'buzz/theme_pagination_glass',
),
)
.width,
116,
);
expect(find.text('Accent color'), findsNothing);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('settings restores accent navigation away from Buzz', (
testWidgets('uses matching native glass bottom actions on iOS', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await _pumpPicker(
tester,
SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
prefs: {
'buzz_theme_mode': 'light',
'buzz_color_scheme': 'github-light',
'buzz_accent_color': 4,
},
);
expect(find.text('Accent color'), findsOneWidget);
});
});
final nativeActions = tester
.widgetList<UiKitView>(find.byType(UiKitView))
.where((view) => view.viewType == 'buzz/navigation_glass');
final accentAction = nativeActions.firstWhere(
(view) =>
(view.creationParams! as Map<String, Object>)['icon'] ==
'colorSwatch',
);
final appearanceAction = nativeActions.firstWhere(
(view) =>
(view.creationParams! as Map<String, Object>)['icon'] == 'sun',
);
final accentParams = accentAction.creationParams! as Map<String, Object>;
final appearanceParams =
appearanceAction.creationParams! as Map<String, Object>;
expect(accentParams['controlSize'], 54);
expect(appearanceParams['controlSize'], 54);
expect(accentParams['swatchColor'], isNotNull);
group('AccentPickerPage', () {
testWidgets('lists every accent and checks the stored one', (tester) async {
await _pumpPicker(
debugDefaultTargetPlatformOverride = null;
});
testWidgets('swipes between drafts and saves only when Set is pressed', (
tester,
) async {
final instance = await _pumpPicker(
tester,
const AccentPickerPage(),
prefs: {'buzz_accent_color': 2},
prefs: {'buzz_theme_mode': 'system', 'buzz_color_scheme': 'buzz'},
);
for (final accent in accentColors) {
expect(find.text(accent.name), findsOneWidget);
}
expect(find.byIcon(LucideIcons.check), findsOneWidget);
await _swipeToNextTheme(tester);
expect(instance.getString('buzz_color_scheme'), 'buzz');
expect(
find.byKey(const ValueKey('theme-preview-name-buzz')),
findsNothing,
);
await tester.tap(find.byKey(const ValueKey('theme-preview-set')));
await tester.pumpAndSettle();
expect(instance.getString('buzz_color_scheme'), isNot('buzz'));
});
testWidgets('tapping an accent persists the index', (tester) async {
final instance = await _prefs(const <String, Object>{});
await tester.pumpWidget(
WidgetHelpers.testable(
child: const AccentPickerPage(),
overrides: [savedPrefsProvider.overrideWithValue(instance)],
),
testWidgets('Set submits one complete theme preference', (tester) async {
final notifier = _RecordingCommunityThemeNotifier();
await _pumpPicker(tester, themeNotifier: () => notifier);
await _swipeToNextTheme(tester);
expect(notifier.preferenceWrites, 0);
await tester.tap(find.byKey(const ValueKey('theme-preview-set')));
await tester.pumpAndSettle();
expect(notifier.preferenceWrites, 1);
expect(notifier.lastPreference?.theme, isNot('buzz'));
expect(notifier.lastPreference?.followSystem, isTrue);
expect(notifier.lastPreference?.accent, defaultCommunityTheme.accent);
});
testWidgets('preserves the stored accent when leaving Buzz', (
tester,
) async {
final green = accentColors.indexWhere((accent) => accent.name == 'Green');
final legacyGreen = green - 1;
final instance = await _pumpPicker(
tester,
prefs: {
'buzz_theme_mode': 'system',
'buzz_color_scheme': 'buzz',
'buzz_accent_color': legacyGreen,
},
);
await _swipeToNextTheme(tester);
await tester.tap(find.byKey(const ValueKey('theme-preview-set')));
await tester.pumpAndSettle();
expect(instance.getString('buzz_color_scheme'), isNot('buzz'));
expect(instance.getInt('buzz_accent_color'), green);
});
testWidgets('accent action scales and fades with theme availability', (
tester,
) async {
await _pumpPicker(
tester,
prefs: {'buzz_theme_mode': 'system', 'buzz_color_scheme': 'buzz'},
);
expect(
find.byKey(const ValueKey('theme-preview-accent-unavailable')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('theme-preview-accent-action-button')),
findsNothing,
);
final pageView = tester.widget<PageView>(
find.byKey(const ValueKey('theme-preview-pages')),
);
pageView.controller!.jumpToPage(1);
await tester.pump();
await tester.pump(const Duration(milliseconds: 80));
expect(
find.byKey(const ValueKey('theme-preview-accent-action-button')),
findsOneWidget,
);
final scaleValues = tester
.widgetList<ScaleTransition>(
find.descendant(
of: find.byKey(
const ValueKey('theme-preview-accent-availability'),
),
matching: find.byType(ScaleTransition),
),
)
.map((transition) => transition.scale.value);
expect(scaleValues.any((value) => value > 0.78 && value < 0.95), isTrue);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('theme-preview-accent-unavailable')),
findsNothing,
);
});
testWidgets('scrubs directly across the pagination control', (
tester,
) async {
final instance = await _pumpPicker(
tester,
prefs: {
'buzz_theme_mode': 'dark',
'buzz_color_scheme': 'catppuccin-macchiato',
},
);
final pageView = tester.widget<PageView>(
find.byKey(const ValueKey('theme-preview-pages')),
);
final scrubber = tester.getRect(
find.byKey(const ValueKey('theme-preview-scrubber')),
);
await tester.tapAt(
Offset(scrubber.left + scrubber.width * 0.85, scrubber.center.dy),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Green'));
expect(pageView.controller!.page, greaterThan(2));
expect(instance.getString('buzz_color_scheme'), 'catppuccin-macchiato');
});
testWidgets('cycles appearance inline with scale and opacity motion', (
tester,
) async {
final instance = await _pumpPicker(
tester,
prefs: {
'buzz_theme_mode': 'light',
'buzz_color_scheme': 'github-light',
},
);
expect(
find.byKey(const ValueKey('theme-appearance-light')),
findsOneWidget,
);
await tester.tap(
find.byKey(const ValueKey('theme-preview-appearance-action-button')),
);
await tester.pump();
expect(find.byKey(const ValueKey('appearance-mode-dark')), findsNothing);
expect(
find.byKey(const ValueKey('theme-appearance-dark')),
findsOneWidget,
);
expect(
find.descendant(
of: find.byKey(const ValueKey('theme-preview-appearance-switcher')),
matching: find.byType(FadeTransition),
),
findsWidgets,
);
await tester.pump(const Duration(milliseconds: 75));
final opacityValues = tester
.widgetList<FadeTransition>(
find.descendant(
of: find.byKey(
const ValueKey('theme-preview-appearance-switcher'),
),
matching: find.byType(FadeTransition),
),
)
.map((transition) => transition.opacity.value);
expect(opacityValues.any((value) => value > 0 && value < 1), isTrue);
await tester.pumpAndSettle();
expect(instance.getString('buzz_theme_mode'), 'light');
await tester.tap(find.byKey(const ValueKey('theme-preview-set')));
await tester.pumpAndSettle();
expect(instance.getString('buzz_theme_mode'), 'dark');
});
testWidgets('appearance changes replace pagination state without motion', (
tester,
) async {
await _pumpPicker(
tester,
prefs: {
'buzz_theme_mode': 'light',
'buzz_color_scheme': 'github-light',
},
);
Duration paginationDuration() => tester
.widgetList<AnimatedPositioned>(
find.descendant(
of: find.byKey(const ValueKey('theme-preview-scrubber')),
matching: find.byType(AnimatedPositioned),
),
)
.first
.duration;
expect(paginationDuration(), const Duration(milliseconds: 150));
await tester.tap(
find.byKey(const ValueKey('theme-preview-appearance-action-button')),
);
await tester.pump();
expect(paginationDuration(), Duration.zero);
});
testWidgets('appearance cycle becomes instant under Reduce Motion', (
tester,
) async {
await _pumpPicker(
tester,
prefs: {'buzz_theme_mode': 'light'},
reduceMotion: true,
);
final switcher = tester.widget<AnimatedSwitcher>(
find.byKey(const ValueKey('theme-preview-appearance-switcher')),
);
expect(switcher.duration, Duration.zero);
await tester.tap(
find.byKey(const ValueKey('theme-preview-appearance-action-button')),
);
await tester.pump();
expect(
find.byKey(const ValueKey('theme-appearance-dark')),
findsOneWidget,
);
});
testWidgets('emits one selection haptic for appearance and scrub choices', (
tester,
) async {
await _pumpPicker(
tester,
prefs: {
'buzz_theme_mode': 'dark',
'buzz_color_scheme': 'catppuccin-macchiato',
},
);
var selectionHaptics = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'HapticFeedback.vibrate' &&
call.arguments == 'HapticFeedbackType.selectionClick') {
selectionHaptics++;
}
return null;
});
addTearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null),
);
await tester.tap(
find.byKey(const ValueKey('theme-preview-appearance-action-button')),
);
await tester.pumpAndSettle();
expect(selectionHaptics, 1);
final scrubber = tester.getRect(
find.byKey(const ValueKey('theme-preview-scrubber')),
);
await tester.tapAt(
Offset(scrubber.left + scrubber.width * 0.75, scrubber.center.dy),
);
await tester.pumpAndSettle();
expect(selectionHaptics, 2);
});
testWidgets('accent remains a sheet and is committed with Set', (
tester,
) async {
final instance = await _pumpPicker(
tester,
prefs: {
'buzz_theme_mode': 'light',
'buzz_color_scheme': 'github-light',
'buzz_accent_color': 0,
},
);
final storedBefore = instance.getInt('buzz_accent_color');
await tester.tap(
find.byKey(const ValueKey('theme-preview-accent-action-button')),
);
await tester.pumpAndSettle();
expect(
instance.getInt('buzz_accent_color'),
accentColors.indexWhere((a) => a.name == 'Green'),
find.byKey(const ValueKey('accent-selection-grid')),
findsOneWidget,
);
final green = accentColors.indexWhere((accent) => accent.name == 'Green');
expect(find.text('Green'), findsNothing);
expect(
tester.getSemantics(find.byKey(ValueKey('accent-option-$green'))).label,
contains('Green accent color'),
);
await tester.tap(find.byKey(ValueKey('accent-option-$green')));
await tester.pumpAndSettle();
expect(instance.getInt('buzz_accent_color'), storedBefore);
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(instance.getInt('buzz_accent_color'), green);
});
testWidgets('accent swatch is optically smaller than its tap target', (
tester,
) async {
await _pumpPicker(
tester,
prefs: {
'buzz_theme_mode': 'light',
'buzz_color_scheme': 'github-light',
},
);
expect(
tester.getSize(
find.byKey(const ValueKey('theme-preview-accent-action-button')),
),
const Size(64, 54),
);
expect(
tester.getSize(
find.byKey(const ValueKey('theme-preview-accent-swatch')),
),
const Size.square(46),
);
});
testWidgets('Buzz keeps the accent action unavailable', (tester) async {
await _pumpPicker(tester, prefs: {'buzz_color_scheme': 'buzz'});
expect(
find.byKey(const ValueKey('theme-preview-accent-action-button')),
findsNothing,
);
});
});
}
class _RecordingCommunityThemeNotifier extends CommunityThemeNotifier {
int preferenceWrites = 0;
CommunityThemePreference? lastPreference;
@override
CommunityThemePreference build() => defaultCommunityTheme;
@override
void setPreference(CommunityThemePreference preference) {
preferenceWrites++;
lastPreference = preference;
state = preference;
}
}

View File

@@ -184,6 +184,71 @@ void main() {
expect(container.read(communityThemeProvider).theme, 'dracula');
},
);
test('complete preference writes one cache and outbox snapshot', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keys = nostr.Keys.generate();
final session = _ThemeRelaySession(keys.nsec, keys.public);
final storage = _RecordingThemeStorage(prefs);
final container = ProviderContainer(
overrides: [
communityThemeStorageProvider.overrideWithValue(storage),
relayConfigProvider.overrideWith(() => _RelayConfig(keys.nsec)),
relaySessionProvider.overrideWith(() => session),
],
);
addTearDown(container.dispose);
final subscription = container.listen(
communityThemeProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(subscription.close);
await _waitUntil(() => storage.cacheWrites.isNotEmpty);
storage.cacheWrites.clear();
storage.outboxWrites.clear();
const selection = CommunityThemePreference(
theme: 'dracula',
accent: '#22c55e',
followSystem: false,
);
container.read(communityThemeProvider.notifier).setPreference(selection);
await _waitUntil(() => storage.outboxWrites.isNotEmpty);
expect(storage.cacheWrites, [selection]);
expect(storage.outboxWrites, [selection]);
expect(container.read(communityThemeProvider), selection);
});
}
class _RecordingThemeStorage extends CommunityThemeStorage {
_RecordingThemeStorage(super.prefs);
final cacheWrites = <CommunityThemePreference>[];
final outboxWrites = <CommunityThemePreference>[];
@override
Future<bool> write(
String pubkey,
String relayUrl,
CommunityThemePreference preference,
) async {
cacheWrites.add(preference);
return super.write(pubkey, relayUrl, preference);
}
@override
Future<bool> writeOutbox(
String pubkey,
String relayUrl,
CommunityThemePreference preference,
) async {
outboxWrites.add(preference);
return super.writeOutbox(pubkey, relayUrl, preference);
}
}
class _DelayedThemeStorage extends CommunityThemeStorage {

View File

@@ -62,11 +62,15 @@ void main() {
}
});
test('groups are sorted by display name', () {
test('Buzz is first and remaining themes are sorted by display name', () {
final groups = themeGroups();
expect(groups.paired.first.name, buzzThemeName);
expect(groups.light.first.name, buzzThemeName);
expect(groups.dark.first.name, buzzDarkThemeName);
for (final list in [groups.paired, groups.light, groups.dark]) {
final names = list.map((t) => t.displayName).toList();
final names = list.skip(1).map((t) => t.displayName).toList();
expect(names, orderedEquals(List.of(names)..sort()));
}
});

View File

@@ -0,0 +1,46 @@
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('utility containers use the stronger grouped-surface radius', () {
expect(Radii.container, 22);
expect(Radii.card, Radii.container);
});
test('exchanges utility canvas and container surface roles once', () {
const pageSurface = Color(0xFFEEEEEE);
const containerSurface = Color(0xFFFFFFFF);
final base = AppTheme.light(
colorScheme: lightColorScheme.copyWith(
surface: containerSurface,
surfaceContainerHighest: pageSurface,
),
);
final utility = utilitySurfaceThemeData(base);
expect(utility.scaffoldBackgroundColor, pageSurface);
expect(utility.colorScheme.surface, pageSurface);
expect(utility.colorScheme.surfaceContainerHighest, containerSurface);
expect(utility.cardTheme.color, containerSurface);
expect(
(utility.cardTheme.shape! as RoundedRectangleBorder).borderRadius,
BorderRadius.circular(Radii.container),
);
for (final border in [
utility.inputDecorationTheme.border,
utility.inputDecorationTheme.enabledBorder,
utility.inputDecorationTheme.focusedBorder,
utility.inputDecorationTheme.errorBorder,
utility.inputDecorationTheme.focusedErrorBorder,
]) {
expect(
(border! as OutlineInputBorder).borderRadius,
BorderRadius.circular(Radii.container),
);
}
expect(utility.bottomSheetTheme.backgroundColor, pageSurface);
expect(utilitySurfaceThemeData(utility), same(utility));
});
}

View File

@@ -0,0 +1,109 @@
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/frosted_scaffold.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Border? _appBarBorder(WidgetTester tester) {
final container = tester.widget<Container>(
find.byKey(const ValueKey('frosted-app-bar-background')),
);
return (container.decoration as BoxDecoration?)?.border as Border?;
}
void main() {
testWidgets('utility pages expose the inverted surface hierarchy', (
tester,
) async {
const pageSurface = Color(0xFFEEEEEE);
const containerSurface = Color(0xFFFFFFFF);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(
colorScheme: lightColorScheme.copyWith(
surface: containerSurface,
surfaceContainerHighest: pageSurface,
),
),
home: FrostedScaffold(
useUtilitySurfaceTheme: true,
appBar: const FrostedAppBar(title: Text('Settings')),
body: Builder(
builder: (context) => Column(
children: [
ColoredBox(
key: const ValueKey('utility-page-surface'),
color: Theme.of(context).colorScheme.surface,
child: const SizedBox.square(dimension: 20),
),
ColoredBox(
key: const ValueKey('utility-container-surface'),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const SizedBox.square(dimension: 20),
),
],
),
),
),
),
);
expect(
tester
.widget<ColoredBox>(
find.byKey(const ValueKey('utility-page-surface')),
)
.color,
pageSurface,
);
expect(
tester
.widget<ColoredBox>(
find.byKey(const ValueKey('utility-container-surface')),
)
.color,
containerSurface,
);
});
testWidgets('page divider appears only after content scrolls under header', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: FrostedScaffold(
appBar: const FrostedAppBar(title: Text('Theme')),
body: ListView.builder(
key: const ValueKey('page-scroll-view'),
padding: const EdgeInsets.only(top: 57),
itemCount: 40,
itemBuilder: (_, index) =>
SizedBox(height: 48, child: Text('Theme option $index')),
),
),
),
);
expect(_appBarBorder(tester)?.bottom.color.a, 0);
await tester.drag(
find.byKey(const ValueKey('page-scroll-view')),
const Offset(0, -120),
);
await tester.pumpAndSettle();
final border = _appBarBorder(tester);
expect(border, isNotNull);
expect(border!.bottom.color.a, greaterThan(0));
await tester.drag(
find.byKey(const ValueKey('page-scroll-view')),
const Offset(0, 500),
);
await tester.pumpAndSettle();
expect(_appBarBorder(tester)?.bottom.color.a, 0);
});
}

View File

@@ -8,6 +8,17 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
Border sheetHeaderBorder(WidgetTester tester) {
final decoration =
tester
.widget<DecoratedBox>(
find.byKey(const ValueKey('buzz-sheet-scroll-divider')),
)
.decoration
as BoxDecoration;
return decoration.border! as Border;
}
testWidgets(
'keeps an opaque Flutter surface when iOS native support is unavailable',
(tester) async {
@@ -166,7 +177,7 @@ void main() {
try {
final darkTheme = AppTheme.dark();
await tester.pumpWidget(themedSurface(darkTheme));
await tester.pump();
await tester.pumpAndSettle();
tester.widget<UiKitView>(find.byType(UiKitView)).onPlatformViewCreated!(
42,
);
@@ -234,7 +245,13 @@ void main() {
onPressed: () => showBuzzModalBottomSheet<void>(
context: context,
title: 'Members',
builder: (_) => const Text('Sheet body'),
builder: (sheetContext) => ColoredBox(
key: const ValueKey('sheet-container-surface'),
color: Theme.of(
sheetContext,
).colorScheme.surfaceContainerHighest,
child: const Text('Sheet body'),
),
),
child: const Text('Open sheet'),
),
@@ -255,7 +272,18 @@ void main() {
);
expect(
nativeSurface.creationParams,
containsPair('color', lightColorScheme.surface.toARGB32()),
containsPair(
'color',
lightColorScheme.surfaceContainerHighest.toARGB32(),
),
);
expect(
tester
.widget<ColoredBox>(
find.byKey(const ValueKey('sheet-container-surface')),
)
.color,
lightColorScheme.surface,
);
expect(nativeSurface.creationParams, isNot(contains('headerGradient')));
expect(
@@ -335,7 +363,7 @@ void main() {
find.byKey(const ValueKey('buzz-sheet-surface')),
)
.color,
lightColorScheme.surface,
lightColorScheme.surfaceContainerHighest,
);
expect(
tester.getTopLeft(find.text('Sheet body')).dy -
@@ -416,6 +444,126 @@ void main() {
},
);
testWidgets('untitled Android sheets paint the utility route surface', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Builder(
builder: (context) => FilledButton(
onPressed: () => showBuzzModalBottomSheet<void>(
context: context,
builder: (sheetContext) => ColoredBox(
key: const ValueKey('untitled-sheet-container'),
color: Theme.of(
sheetContext,
).colorScheme.surfaceContainerHighest,
child: const SizedBox(
height: 80,
child: Text('Sheet body'),
),
),
),
child: const Text('Open sheet'),
),
),
),
),
);
await tester.tap(find.text('Open sheet'));
await tester.pumpAndSettle();
expect(
tester.widget<BottomSheet>(find.byType(BottomSheet)).backgroundColor,
lightColorScheme.surfaceContainerHighest,
);
expect(
tester
.widget<ColoredBox>(
find.byKey(const ValueKey('untitled-sheet-container')),
)
.color,
lightColorScheme.surface,
);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets('sheet divider appears only when content scrolls under header', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Builder(
builder: (context) => FilledButton(
onPressed: () => showBuzzModalBottomSheet<void>(
context: context,
title: 'Theme',
isScrollControlled: true,
builder: (_) => SizedBox(
height: 360,
child: ListView.builder(
key: const ValueKey('sheet-scroll-view'),
itemCount: 30,
itemBuilder: (_, index) =>
SizedBox(height: 44, child: Text('Option $index')),
),
),
),
child: const Text('Open sheet'),
),
),
),
),
);
await tester.tap(find.text('Open sheet'));
await tester.pumpAndSettle();
expect(sheetHeaderBorder(tester).bottom.color.a, 0);
await tester.drag(
find.byKey(const ValueKey('sheet-scroll-view')),
const Offset(0, -100),
);
await tester.pumpAndSettle();
expect(
tester
.state<ScrollableState>(
find.descendant(
of: find.byKey(const ValueKey('sheet-scroll-view')),
matching: find.byType(Scrollable),
),
)
.position
.pixels,
greaterThan(0),
);
expect(sheetHeaderBorder(tester).bottom.color.a, greaterThan(0));
await tester.drag(
find.byKey(const ValueKey('sheet-scroll-view')),
const Offset(0, 500),
);
await tester.pumpAndSettle();
expect(sheetHeaderBorder(tester).bottom.color.a, 0);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets('iOS paints the drag handle inside the concentric surface', (
tester,
) async {