mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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 */,
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
277
mobile/ios/Runner/ThemePaginationGlassControl.swift
Normal file
277
mobile/ios/Runner/ThemePaginationGlassControl.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
274
mobile/lib/features/settings/theme_option_sheets.dart
Normal file
274
mobile/lib/features/settings/theme_option_sheets.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
77
mobile/lib/shared/theme/utility_surface_theme.dart
Normal file
77
mobile/lib/shared/theme/utility_surface_theme.dart
Normal 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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
5
mobile/lib/shared/widgets/buzz_navigation_metrics.dart
Normal file
5
mobile/lib/shared/widgets/buzz_navigation_metrics.dart
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
131
mobile/lib/shared/widgets/editor_option_button.dart
Normal file
131
mobile/lib/shared/widgets/editor_option_button.dart
Normal 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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
19
mobile/lib/shared/widgets/frosted_scroll_under_scope.dart
Normal file
19
mobile/lib/shared/widgets/frosted_scroll_under_scope.dart
Normal 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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
96
mobile/lib/shared/widgets/ios_glass_theme_pagination.dart
Normal file
96
mobile/lib/shared/widgets/ios_glass_theme_pagination.dart
Normal 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');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
|
||||
46
mobile/test/shared/theme/utility_surface_theme_test.dart
Normal file
46
mobile/test/shared/theme/utility_surface_theme_test.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
109
mobile/test/shared/widgets/frosted_scaffold_test.dart
Normal file
109
mobile/test/shared/widgets/frosted_scaffold_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user