fix(mobile): polish photo carousels and video viewer controls (#8079)

## Summary
- Keep neighboring carousel photos visible, add page-change haptics, and
reuse appearance-style dots with native iOS glass and grouped actions.
- Refine full-screen video controls with fading transport, cycling
speeds, pinch-to-zoom, and double-tap reset before swipe-to-dismiss.

### Related issue
N/A. Checked recent open PRs; repository issue search was unavailable.

### Testing
- Mobile analysis/format checks, file-size guards, and full Flutter
suite passed (2,680 tests; 4 skipped).
- Built, installed, and manually verified on the connected iPhone.
- `just ci` was attempted but blocked fetching a Rust dependency because
this Mac could not connect to GitHub.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: klopez4212 <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-10-04 17:17:46 +01:00
committed by GitHub
parent 5f2d801b8f
commit f0eb5575ff
28 changed files with 2727 additions and 733 deletions

View File

@@ -135,8 +135,17 @@ jobs:
run: swift build -c release --package-path mobile/ios/BuzzPushKit run: swift build -c release --package-path mobile/ios/BuzzPushKit
- name: Test - name: Test
run: swift test --package-path mobile/ios/BuzzPushKit run: swift test --package-path mobile/ios/BuzzPushKit
- name: Test native pagination geometry
run: /bin/bash scripts/test-mobile-pagination.sh
- name: Build complete iOS simulator app - name: Build complete iOS simulator app
run: cd mobile && flutter build ios --simulator --debug --no-pub run: cd mobile && flutter build ios --simulator --debug --no-pub
- name: Test native pagination accessibility
run: |
device=$(xcrun simctl create "Pagination accessibility" "iPhone 16")
trap 'xcrun simctl shutdown "$device"; xcrun simctl delete "$device"' EXIT
xcrun simctl boot "$device"
xcrun simctl bootstatus "$device" -b
/bin/bash scripts/test-mobile-pagination-accessibility.sh "$device"
- name: Build complete unsigned iOS release - name: Build complete unsigned iOS release
run: | run: |
/bin/bash scripts/test-mobile-gateway-recipes.sh /bin/bash scripts/test-mobile-gateway-recipes.sh

View File

@@ -0,0 +1,59 @@
import Flutter
import UIKit
@main
struct ThemePaginationAccessibilityTests {
static func check(_ condition: Bool, _ message: String) {
precondition(condition, message)
}
@MainActor static func main() {
let messenger = PaginationTestMessenger()
let platform = ThemePaginationGlassControlPlatformView(
frame: CGRect(x: 0, y: 0, width: 116, height: 54),
viewIdentifier: 42,
arguments: ["accessibilityLabel": "Photo", "count": 3, "selected": 1],
messenger: messenger
)
let control = platform.view()
control.layoutIfNeeded()
func accessibleViews(_ view: UIView) -> [UIView] {
(view.isAccessibilityElement ? [view] : []) + view.subviews.flatMap(accessibleViews)
}
check(accessibleViews(control).count == 1, "Exactly one native accessibility element")
check(control.accessibilityTraits.contains(.adjustable), "Paginator must be adjustable")
check(control.accessibilityLabel == "Photo", "Native label must match content")
check(control.accessibilityValue == "2 of 3", "Initial page value")
control.accessibilityIncrement()
check(control.accessibilityValue == "3 of 3", "Increment updates value")
check(messenger.selections == [2], "Increment is delivered to Flutter once")
control.accessibilityIncrement()
check(messenger.selections == [2], "Increment stops at last page")
control.accessibilityDecrement()
control.accessibilityDecrement()
control.accessibilityDecrement()
check(control.accessibilityValue == "1 of 3", "Decrement stops at first page")
check(messenger.selections == [2, 1, 0], "Each decrement is delivered once")
check(accessibleViews(control).count == 1, "Selection keeps a single owner")
print("Native pagination accessibility passed")
}
}
private final class PaginationTestMessenger: NSObject, FlutterBinaryMessenger {
var selections: [Int] = []
func send(onChannel channel: String, message: Data?) {
guard let message else { return }
let call = FlutterStandardMethodCodec.sharedInstance().decodeMethodCall(message)
if call.method == "selected", let selected = call.arguments as? Int {
selections.append(selected)
}
}
func send(onChannel channel: String, message: Data?, binaryReply callback: FlutterBinaryReply?) {
send(onChannel: channel, message: message)
callback?(nil)
}
func setMessageHandlerOnChannel(
_ channel: String, binaryMessageHandler handler: FlutterBinaryMessageHandler?
) -> FlutterBinaryMessengerConnection { 1 }
func cleanUpConnection(_ connection: FlutterBinaryMessengerConnection) {}
}

View File

@@ -0,0 +1,64 @@
import Foundation
@main
struct ThemePaginationGeometryTests {
static func check(_ condition: Bool, _ message: String) {
if !condition {
print("FAIL: \(message)")
exit(1)
}
}
static func main() {
let windows = [(3, 1, 0), (20, 0, 0), (20, 10, 7), (20, 19, 13)]
var checkedCenters = 0
for isRTL in [false, true] {
for (count, selected, firstPage) in windows {
// Actual dot centers inside the 92-point padded track.
let centers: [CGFloat] = count == 3 ? [34, 46, 58] : [10, 22, 34, 46, 58, 70, 82]
let geometry = ThemePaginationGeometry(count: count, selected: selected, width: 92, isRTL: isRTL)
check(geometry.windowStart == firstPage, "wrong window at \(selected)")
for (slot, center) in centers.enumerated() {
let page = firstPage + (isRTL ? centers.count - 1 - slot : slot)
check(geometry.centerX(for: page) == center, "wrong rendered center for page \(page)")
for offset: CGFloat in [-2, 0, 2] {
check(geometry.page(at: center + offset) == page,
"count=\(count), selected=\(selected), RTL=\(isRTL), tap=\(center + offset), expected=\(page)")
}
checkedCenters += 1
}
check(geometry.page(at: -100) == firstPage + (isRTL ? centers.count - 1 : 0), "left edge")
check(geometry.page(at: 200) == firstPage + (isRTL ? 0 : centers.count - 1), "right edge")
}
}
for isRTL in [false, true] {
for cancel in [false, true] {
var selected = 10
func current() -> ThemePaginationGeometry {
ThemePaginationGeometry(count: 20, selected: selected, width: 92, isRTL: isRTL)
}
var scrub = ThemePaginationScrub()
let target = current().centerX(for: 12)
scrub.begin(current())
for _ in 0..<8 {
selected = scrub.page(at: target, current: current())
check(selected == 12, "held pan cascaded in RTL=\(isRTL), cancel=\(cancel)")
check(scrub.geometry?.centerX(for: 12) == target, "rendered window moved")
}
// The UIKit ended path consumes its last coordinate before clearing;
// cancelled/failed paths clear without introducing another selection.
if !cancel { selected = scrub.page(at: target, current: current()) }
scrub.end()
check(scrub.geometry == nil, "end/cancel retained window")
let nextTarget = current().centerX(for: 14)
scrub.begin(current())
selected = scrub.page(at: nextTarget, current: current())
check(selected == 14, "next gesture reused stale geometry")
scrub.end()
check(current().page(at: current().centerX(for: 15)) == 15, "tap after scrub")
}
}
print("PASS: held scrubs, final events, cancellation and fresh gestures in LTR/RTL")
print("PASS: \(checkedCenters) rendered centers, neighboring taps and clamped edges in LTR/RTL")
}
}

View File

@@ -31,6 +31,7 @@
4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */; }; 4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */; };
4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */; }; 4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */; };
4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */; }; 4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */; };
AC7400012F50000100A17E01 /* ThemePaginationGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7400022F50000100A17E01 /* ThemePaginationGeometry.swift */; };
4A71C01F2F4100100A17E01 /* VoiceNotePackager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */; }; 4A71C01F2F4100100A17E01 /* VoiceNotePackager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */; };
4A71C0212F4110100A17E01 /* MP4Canonicalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0222F4110100A17E01 /* MP4Canonicalizer.swift */; }; 4A71C0212F4110100A17E01 /* MP4Canonicalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0222F4110100A17E01 /* MP4Canonicalizer.swift */; };
4A71C0232F4120100A17E01 /* NativeAttachmentPopoverMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0242F4120100A17E01 /* NativeAttachmentPopoverMenu.swift */; }; 4A71C0232F4120100A17E01 /* NativeAttachmentPopoverMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0242F4120100A17E01 /* NativeAttachmentPopoverMenu.swift */; };
@@ -116,6 +117,7 @@
4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeProfileTextEditor.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>"; }; 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>"; }; 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePaginationGlassControl.swift; sourceTree = "<group>"; };
AC7400022F50000100A17E01 /* ThemePaginationGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePaginationGeometry.swift; sourceTree = "<group>"; };
4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceNotePackager.swift; sourceTree = "<group>"; }; 4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceNotePackager.swift; sourceTree = "<group>"; };
4A71C0222F4110100A17E01 /* MP4Canonicalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MP4Canonicalizer.swift; sourceTree = "<group>"; }; 4A71C0222F4110100A17E01 /* MP4Canonicalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MP4Canonicalizer.swift; sourceTree = "<group>"; };
4A71C0242F4120100A17E01 /* NativeAttachmentPopoverMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopoverMenu.swift; sourceTree = "<group>"; }; 4A71C0242F4120100A17E01 /* NativeAttachmentPopoverMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopoverMenu.swift; sourceTree = "<group>"; };
@@ -283,6 +285,7 @@
4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */, 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */,
4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */, 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */,
4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */, 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */,
AC7400022F50000100A17E01 /* ThemePaginationGeometry.swift */,
4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */, 4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */,
4A71C0222F4110100A17E01 /* MP4Canonicalizer.swift */, 4A71C0222F4110100A17E01 /* MP4Canonicalizer.swift */,
4A71C0242F4120100A17E01 /* NativeAttachmentPopoverMenu.swift */, 4A71C0242F4120100A17E01 /* NativeAttachmentPopoverMenu.swift */,
@@ -616,6 +619,7 @@
4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */, 4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */,
4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */, 4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */,
4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */, 4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */,
AC7400012F50000100A17E01 /* ThemePaginationGeometry.swift in Sources */,
4A71C01F2F4100100A17E01 /* VoiceNotePackager.swift in Sources */, 4A71C01F2F4100100A17E01 /* VoiceNotePackager.swift in Sources */,
4A71C0212F4110100A17E01 /* MP4Canonicalizer.swift in Sources */, 4A71C0212F4110100A17E01 /* MP4Canonicalizer.swift in Sources */,
4A71C0232F4120100A17E01 /* NativeAttachmentPopoverMenu.swift in Sources */, 4A71C0232F4120100A17E01 /* NativeAttachmentPopoverMenu.swift in Sources */,

View File

@@ -411,6 +411,8 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
case "shutter": buttonIconName = "circle.fill" case "shutter": buttonIconName = "circle.fill"
case "sun": buttonIconName = "sun.max" case "sun": buttonIconName = "sun.max"
case "moon": buttonIconName = "moon" case "moon": buttonIconName = "moon"
case "more": buttonIconName = "ellipsis"
case "reply": buttonIconName = "arrowshape.turn.up.left"
case "systemAppearance": buttonIconName = "circle.lefthalf.filled" case "systemAppearance": buttonIconName = "circle.lefthalf.filled"
case "colorSwatch": buttonIconName = "circle.fill" case "colorSwatch": buttonIconName = "circle.fill"
default: buttonIconName = "chevron.backward" default: buttonIconName = "chevron.backward"

View File

@@ -0,0 +1,52 @@
import Foundation
/// Shared geometry for native pagination drawing and pointer selection.
struct ThemePaginationGeometry {
static let dotSize: CGFloat = 6
static let pitch: CGFloat = 12
let count: Int
let selected: Int
let width: CGFloat
let isRTL: Bool
var visibleCount: Int { min(count, 7) }
var windowStart: Int { min(max(0, selected - visibleCount / 2), count - visibleCount) }
var hasEarlierDots: Bool { windowStart > 0 }
var hasLaterDots: Bool { windowStart + visibleCount < count }
private var firstCenter: CGFloat {
let trackWidth = CGFloat(visibleCount - 1) * Self.pitch + Self.dotSize
return (width - trackWidth) / 2 + Self.dotSize / 2
}
func slot(for page: Int) -> Int {
let logicalSlot = page - windowStart
return isRTL ? visibleCount - 1 - logicalSlot : logicalSlot
}
func centerX(for page: Int) -> CGFloat {
firstCenter + CGFloat(slot(for: page)) * Self.pitch
}
func page(at x: CGFloat) -> Int {
let slot = Int(((x - firstCenter) / Self.pitch).rounded())
let boundedSlot = min(max(0, slot), visibleCount - 1)
return windowStart + (isRTL ? visibleCount - 1 - boundedSlot : boundedSlot)
}
}
/// Freezes the rendered window for one continuous pan, including its final event.
struct ThemePaginationScrub {
private(set) var geometry: ThemePaginationGeometry?
mutating func begin(_ geometry: ThemePaginationGeometry) {
self.geometry = geometry
}
func page(at x: CGFloat, current: ThemePaginationGeometry) -> Int {
(geometry ?? current).page(at: x)
}
mutating func end() {
geometry = nil
}
}

View File

@@ -28,15 +28,15 @@ final class ThemePaginationGlassControlFactory: NSObject, FlutterPlatformViewFac
} }
private final class ThemePaginationControl: UIControl { private final class ThemePaginationControl: UIControl {
private static let maximumVisibleDots = 7 private static let fullDotSize = ThemePaginationGeometry.dotSize
private static let fullDotSize: CGFloat = 6
private static let selectedDotSize: CGFloat = 10 private static let selectedDotSize: CGFloat = 10
private static let dotSpacing: CGFloat = 6
private let glassView: UIVisualEffectView private let glassView: UIVisualEffectView
private let dotsContainer = UIView() private let dotsContainer = UIView()
private var glassHeightConstraint: NSLayoutConstraint?
private var dots: [UIView] = [] private var dots: [UIView] = []
private var totalCount = 1 private var totalCount = 1
private var selectedIndex = 0 private var selectedIndex = 0
private var scrub = ThemePaginationScrub()
private var activeColor = UIColor.label private var activeColor = UIColor.label
private var inactiveColor = UIColor.secondaryLabel.withAlphaComponent(0.32) private var inactiveColor = UIColor.secondaryLabel.withAlphaComponent(0.32)
var onSelectionChanged: ((Int) -> Void)? var onSelectionChanged: ((Int) -> Void)?
@@ -53,6 +53,7 @@ private final class ThemePaginationControl: UIControl {
backgroundColor = .clear backgroundColor = .clear
isOpaque = false isOpaque = false
isAccessibilityElement = true
accessibilityTraits = [.adjustable] accessibilityTraits = [.adjustable]
accessibilityLabel = "Theme" accessibilityLabel = "Theme"
@@ -67,11 +68,13 @@ private final class ThemePaginationControl: UIControl {
dotsContainer.isUserInteractionEnabled = false dotsContainer.isUserInteractionEnabled = false
glassView.contentView.addSubview(dotsContainer) glassView.contentView.addSubview(dotsContainer)
let glassHeightConstraint = glassView.heightAnchor.constraint(equalToConstant: 30)
self.glassHeightConstraint = glassHeightConstraint
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
glassView.leadingAnchor.constraint(equalTo: leadingAnchor), glassView.leadingAnchor.constraint(equalTo: leadingAnchor),
glassView.trailingAnchor.constraint(equalTo: trailingAnchor), glassView.trailingAnchor.constraint(equalTo: trailingAnchor),
glassView.centerYAnchor.constraint(equalTo: centerYAnchor), glassView.centerYAnchor.constraint(equalTo: centerYAnchor),
glassView.heightAnchor.constraint(equalToConstant: 30), glassHeightConstraint,
dotsContainer.leadingAnchor.constraint(equalTo: glassView.contentView.leadingAnchor, constant: 12), dotsContainer.leadingAnchor.constraint(equalTo: glassView.contentView.leadingAnchor, constant: 12),
dotsContainer.trailingAnchor.constraint(equalTo: glassView.contentView.trailingAnchor, constant: -12), dotsContainer.trailingAnchor.constraint(equalTo: glassView.contentView.trailingAnchor, constant: -12),
dotsContainer.topAnchor.constraint(equalTo: glassView.contentView.topAnchor), dotsContainer.topAnchor.constraint(equalTo: glassView.contentView.topAnchor),
@@ -108,6 +111,15 @@ private final class ThemePaginationControl: UIControl {
inactiveColor = Self.color(fromARGB: value.uint32Value) inactiveColor = Self.color(fromARGB: value.uint32Value)
} }
accessibilityLabel = arguments["accessibilityLabel"] as? String ?? accessibilityLabel
if let height = arguments["containerHeight"] as? NSNumber {
glassHeightConstraint?.constant = max(10, CGFloat(height.doubleValue))
setNeedsLayout()
}
if let isRTL = arguments["isRTL"] as? Bool {
semanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight
}
if count != totalCount { scrub.end() }
totalCount = count totalCount = count
if count != dots.count { if count != dots.count {
rebuildDots(count: count) rebuildDots(count: count)
@@ -126,11 +138,39 @@ private final class ThemePaginationControl: UIControl {
select(index: max(selectedIndex - 1, 0)) select(index: max(selectedIndex - 1, 0))
} }
private var geometry: ThemePaginationGeometry {
ThemePaginationGeometry(
count: totalCount, selected: selectedIndex, width: dotsContainer.bounds.width,
isRTL: effectiveUserInterfaceLayoutDirection == .rightToLeft
)
}
@objc private func handleGesture(_ recognizer: UIGestureRecognizer) { @objc private func handleGesture(_ recognizer: UIGestureRecognizer) {
guard totalCount > 0 else { return } guard dotsContainer.bounds.width > 0 else { return }
let location = recognizer.location(in: glassView) let location = recognizer.location(in: dotsContainer)
let progress = max(0, min(0.999_999, location.x / max(1, glassView.bounds.width))) if recognizer is UIPanGestureRecognizer {
select(index: Int(progress * CGFloat(totalCount))) switch recognizer.state {
case .began:
scrub.begin(geometry)
case .changed:
guard scrub.geometry != nil else { return }
case .ended:
let page = scrub.page(at: location.x, current: geometry)
scrub.end()
select(index: page)
updateDots(animated: true)
return
case .cancelled, .failed:
scrub.end()
updateDots(animated: true)
return
default:
return
}
select(index: scrub.page(at: location.x, current: geometry))
} else if recognizer.state == .ended {
select(index: geometry.page(at: location.x))
}
} }
private func select(index: Int) { private func select(index: Int) {
@@ -163,20 +203,13 @@ private final class ThemePaginationControl: UIControl {
private func updateDots(animated: Bool) { private func updateDots(animated: Bool) {
guard !dots.isEmpty, dotsContainer.bounds.width > 0 else { return } guard !dots.isEmpty, dotsContainer.bounds.width > 0 else { return }
let visibleCount = min(totalCount, Self.maximumVisibleDots) let geometry = scrub.geometry ?? self.geometry
let maximumStart = max(0, totalCount - visibleCount) let visibleCount = geometry.visibleCount
let centerSlot = visibleCount / 2 let hasEarlierDots = geometry.isRTL ? geometry.hasLaterDots : geometry.hasEarlierDots
let windowStart = min(max(0, selectedIndex - centerSlot), maximumStart) let hasLaterDots = geometry.isRTL ? geometry.hasEarlierDots : geometry.hasLaterDots
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 = { let changes = {
for (page, dot) in self.dots.enumerated() { for (page, dot) in self.dots.enumerated() {
let slot = page - windowStart let slot = geometry.slot(for: page)
let isVisible = (0..<visibleCount).contains(slot) let isVisible = (0..<visibleCount).contains(slot)
var diameter = Self.fullDotSize var diameter = Self.fullDotSize
if page == self.selectedIndex { if page == self.selectedIndex {
@@ -191,7 +224,7 @@ private final class ThemePaginationControl: UIControl {
diameter = 4 diameter = 4
} }
dot.center = CGPoint( dot.center = CGPoint(
x: trackOrigin + Self.fullDotSize / 2 + CGFloat(slot) * pitch, x: geometry.centerX(for: page),
y: self.dotsContainer.bounds.midY y: self.dotsContainer.bounds.midY
) )
dot.alpha = isVisible ? 1 : 0 dot.alpha = isVisible ? 1 : 0

View File

@@ -1,9 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:ui' show ImageFilter;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/physics.dart'; import 'package:flutter/physics.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@@ -14,6 +16,8 @@ import 'package:video_player/video_player.dart';
import '../../shared/relay/relay.dart'; import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart'; import '../../shared/theme/theme.dart';
import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/concentric_sheet_surface.dart';
import '../../shared/widgets/page_indicator.dart';
import '../../shared/widgets/ios_glass_navigation_button.dart'; import '../../shared/widgets/ios_glass_navigation_button.dart';
import '../../shared/widgets/ios_navigation_bar.dart'; import '../../shared/widgets/ios_navigation_bar.dart';
import 'media_viewer_hero.dart'; import 'media_viewer_hero.dart';
@@ -23,7 +27,9 @@ export 'media_viewer_hero.dart';
part 'media_viewer_page/image_controls.dart'; part 'media_viewer_page/image_controls.dart';
part 'media_viewer_page/route_transition.dart'; part 'media_viewer_page/route_transition.dart';
part 'media_viewer_page/video_controls.dart'; part 'media_viewer_page/video_controls.dart';
part 'media_viewer_page/video_controls_visibility.dart';
part 'media_viewer_page/video_viewer.dart'; part 'media_viewer_page/video_viewer.dart';
part 'media_viewer_page/video_zoom_surface.dart';
const _imageViewerPushDuration = Duration(milliseconds: 260); const _imageViewerPushDuration = Duration(milliseconds: 260);
const _imageViewerPopDuration = Duration(milliseconds: 170); const _imageViewerPopDuration = Duration(milliseconds: 170);
@@ -191,7 +197,6 @@ class MediaImageViewerPage extends HookConsumerWidget {
static const _dismissThreshold = 100.0; static const _dismissThreshold = 100.0;
static const _dismissVelocity = 700.0; static const _dismissVelocity = 700.0;
static const _backgroundFadeDivisor = 300.0; static const _backgroundFadeDivisor = 300.0;
static const _filmstripScrubExtent = 44.0;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -210,7 +215,6 @@ class MediaImageViewerPage extends HookConsumerWidget {
final safeInitialIndex = initialIndex.clamp(0, images.length - 1).toInt(); final safeInitialIndex = initialIndex.clamp(0, images.length - 1).toInt();
final currentIndex = useState(safeInitialIndex); final currentIndex = useState(safeInitialIndex);
final pageController = usePageController(initialPage: safeInitialIndex); final pageController = usePageController(initialPage: safeInitialIndex);
final pagePosition = useState(safeInitialIndex.toDouble());
final transformationControllers = useMemoized( final transformationControllers = useMemoized(
() => [ () => [
for (var index = 0; index < images.length; index++) for (var index = 0; index < images.length; index++)
@@ -231,16 +235,6 @@ class MediaImageViewerPage extends HookConsumerWidget {
final dragOffset = useState(0.0); final dragOffset = useState(0.0);
final isDragging = useState(false); final isDragging = useState(false);
void handlePagePositionChanged() {
if (!pageController.hasClients) return;
final nextPosition = pageController.page;
if (nextPosition == null ||
(nextPosition - pagePosition.value).abs() < 0.0001) {
return;
}
pagePosition.value = nextPosition;
}
void handleTransformChanged(int index) { void handleTransformChanged(int index) {
if (index != currentIndex.value) return; if (index != currentIndex.value) return;
final nextIsTransformed = _hasImageTransform( final nextIsTransformed = _hasImageTransform(
@@ -257,11 +251,6 @@ class MediaImageViewerPage extends HookConsumerWidget {
} }
} }
useEffect(() {
pageController.addListener(handlePagePositionChanged);
return () => pageController.removeListener(handlePagePositionChanged);
}, [pageController]);
useEffect(() { useEffect(() {
final listeners = <VoidCallback>[]; final listeners = <VoidCallback>[];
for (var index = 0; index < transformationControllers.length; index++) { for (var index = 0; index < transformationControllers.length; index++) {
@@ -299,6 +288,9 @@ class MediaImageViewerPage extends HookConsumerWidget {
}, [zoomResetController]); }, [zoomResetController]);
void onPageChanged(int index) { void onPageChanged(int index) {
if (index != currentIndex.value) {
unawaited(HapticFeedback.selectionClick());
}
_precacheViewerImages(context, images, index); _precacheViewerImages(context, images, index);
currentIndex.value = index; currentIndex.value = index;
isTransformed.value = _hasImageTransform( isTransformed.value = _hasImageTransform(
@@ -309,34 +301,6 @@ class MediaImageViewerPage extends HookConsumerWidget {
isDragging.value = false; isDragging.value = false;
} }
void onFilmstripScrubUpdate(double delta) {
if (isTransformed.value || !pageController.hasClients) return;
final position = pageController.position;
final viewport = position.viewportDimension;
if (viewport <= 0) return;
final target =
(pageController.offset - ((delta / _filmstripScrubExtent) * viewport))
.clamp(position.minScrollExtent, position.maxScrollExtent)
.toDouble();
pageController.jumpTo(target);
}
void onFilmstripScrubEnd() {
if (!pageController.hasClients) return;
final targetPage = (pageController.page ?? currentIndex.value.toDouble())
.round()
.clamp(0, images.length - 1);
if (MediaQuery.disableAnimationsOf(context)) {
pageController.jumpToPage(targetPage);
return;
}
pageController.animateToPage(
targetPage,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
);
}
void upgradeToFullResolution(int index) { void upgradeToFullResolution(int index) {
if (images[index].previewDecodeWidth == null || if (images[index].previewDecodeWidth == null ||
fullResolutionIndices.value.contains(index)) { fullResolutionIndices.value.contains(index)) {
@@ -528,12 +492,8 @@ class MediaImageViewerPage extends HookConsumerWidget {
onPageChanged: onPageChanged, onPageChanged: onPageChanged,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final image = images[index]; final image = images[index];
final viewPadding = MediaQuery.viewPaddingOf(context);
return Padding( return Padding(
padding: EdgeInsets.only( padding: _mediaViewerPadding(context),
top: viewPadding.top + 48 + Grid.xxs,
bottom: viewPadding.bottom + 56 + (Grid.xxs * 2),
),
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final viewerSize = _imageViewerSize( final viewerSize = _imageViewerSize(
@@ -621,9 +581,6 @@ class MediaImageViewerPage extends HookConsumerWidget {
child: _MediaViewerBottomControls( child: _MediaViewerBottomControls(
images: images, images: images,
currentIndex: currentIndex.value, currentIndex: currentIndex.value,
pagePosition: pagePosition,
onScrubUpdate: onFilmstripScrubUpdate,
onScrubEnd: onFilmstripScrubEnd,
onSelect: (index) { onSelect: (index) {
if (index == currentIndex.value) return; if (index == currentIndex.value) return;
if (MediaQuery.disableAnimationsOf(context)) { if (MediaQuery.disableAnimationsOf(context)) {

View File

@@ -3,9 +3,6 @@ part of '../media_viewer_page.dart';
class _MediaViewerBottomControls extends StatelessWidget { class _MediaViewerBottomControls extends StatelessWidget {
final List<MediaViewerImage> images; final List<MediaViewerImage> images;
final int currentIndex; final int currentIndex;
final ValueListenable<double> pagePosition;
final ValueChanged<double> onScrubUpdate;
final VoidCallback onScrubEnd;
final ValueChanged<int> onSelect; final ValueChanged<int> onSelect;
final VoidCallback? onReply; final VoidCallback? onReply;
final VoidCallback? onMore; final VoidCallback? onMore;
@@ -13,9 +10,6 @@ class _MediaViewerBottomControls extends StatelessWidget {
const _MediaViewerBottomControls({ const _MediaViewerBottomControls({
required this.images, required this.images,
required this.currentIndex, required this.currentIndex,
required this.pagePosition,
required this.onScrubUpdate,
required this.onScrubEnd,
required this.onSelect, required this.onSelect,
required this.onReply, required this.onReply,
required this.onMore, required this.onMore,
@@ -37,14 +31,19 @@ class _MediaViewerBottomControls extends StatelessWidget {
const SizedBox(width: Grid.xxs), const SizedBox(width: Grid.xxs),
Expanded( Expanded(
child: images.length > 1 child: images.length > 1
? _MediaViewerFilmstrip( ? Theme(
key: const ValueKey('message-media-image-viewer-filmstrip'), data: ThemeData.dark(),
images: images, child: PageIndicator(
currentIndex: currentIndex, key: const ValueKey(
pagePosition: pagePosition, 'message-media-image-viewer-pagination',
onScrubUpdate: onScrubUpdate, ),
onScrubEnd: onScrubEnd, semanticLabel: 'Image',
onSelect: onSelect, containerHeight: 48,
count: images.length,
selected: currentIndex,
animateChanges: !MediaQuery.disableAnimationsOf(context),
onSelected: onSelect,
),
) )
: const SizedBox(height: 56), : const SizedBox(height: 56),
), ),
@@ -61,171 +60,6 @@ class _MediaViewerBottomControls extends StatelessWidget {
} }
} }
class _MediaViewerFilmstrip extends StatelessWidget {
final List<MediaViewerImage> images;
final int currentIndex;
final ValueListenable<double> pagePosition;
final ValueChanged<double> onScrubUpdate;
final VoidCallback onScrubEnd;
final ValueChanged<int> onSelect;
const _MediaViewerFilmstrip({
super.key,
required this.images,
required this.currentIndex,
required this.pagePosition,
required this.onScrubUpdate,
required this.onScrubEnd,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 56,
child: RepaintBoundary(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (details) =>
onScrubUpdate(details.primaryDelta ?? 0),
onHorizontalDragEnd: (_) => onScrubEnd(),
child: ValueListenableBuilder<double>(
valueListenable: pagePosition,
builder: (context, position, _) {
return LayoutBuilder(
builder: (context, constraints) {
const compactWidth = 40.0;
const focusedWidth = 72.0;
const itemHeight = 52.0;
const spacing = Grid.half;
final clampedPosition = position
.clamp(0.0, images.length - 1.0)
.toDouble();
final widths = <double>[];
final proximities = <double>[];
final centers = <double>[];
var cursor = 0.0;
for (var index = 0; index < images.length; index++) {
final proximity = (1 - (index - clampedPosition).abs())
.clamp(0.0, 1.0)
.toDouble();
final width =
compactWidth +
((focusedWidth - compactWidth) * proximity);
widths.add(width);
proximities.add(proximity);
centers.add(cursor + (width / 2));
cursor += width + spacing;
}
final lowerIndex = clampedPosition.floor();
final upperIndex = clampedPosition.ceil();
final fraction = clampedPosition - lowerIndex;
final lowerCenter = centers[lowerIndex];
final upperCenter = centers[upperIndex];
final focusCenter =
lowerCenter + ((upperCenter - lowerCenter) * fraction);
final viewportCenter = constraints.maxWidth / 2;
return Stack(
clipBehavior: Clip.hardEdge,
children: [
for (var index = 0; index < images.length; index++)
Positioned(
left:
viewportCenter +
centers[index] -
focusCenter -
(widths[index] / 2),
top: Grid.quarter,
width: widths[index],
height: itemHeight,
child: _MediaViewerFilmstripImage(
image: images[index],
index: index,
proximity: proximities[index],
selected: index == currentIndex,
onSelect: onSelect,
),
),
],
);
},
);
},
),
),
),
);
}
}
class _MediaViewerFilmstripImage extends StatelessWidget {
final MediaViewerImage image;
final int index;
final double proximity;
final bool selected;
final ValueChanged<int> onSelect;
const _MediaViewerFilmstripImage({
required this.image,
required this.index,
required this.proximity,
required this.selected,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
final borderWidth = 1 + (1.5 * proximity);
return Semantics(
button: true,
selected: selected,
label: selected
? 'Image ${index + 1}, selected'
: 'Show image ${index + 1}',
child: GestureDetector(
key: ValueKey('message-media-image-viewer-thumbnail:$index'),
onTap: () => onSelect(index),
child: Container(
height: 52,
padding: EdgeInsets.all(borderWidth),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.sm),
border: Border.all(
color: Colors.white.withValues(alpha: 0.28 + (0.72 * proximity)),
width: borderWidth,
),
),
child: ClipRRect(
key: ValueKey('message-media-image-viewer-thumbnail-clip:$index'),
borderRadius: BorderRadius.circular(Radii.sm - borderWidth),
clipBehavior: Clip.antiAlias,
child: Opacity(
opacity: 0.62 + (0.38 * proximity),
child: MediaImage(
url: image.url,
decodeWidth: 72,
fit: BoxFit.cover,
semanticLabel: image.semanticLabel,
errorBuilder: (_, _, _) => const ColoredBox(
color: Color.fromRGBO(255, 255, 255, 0.12),
child: Icon(
LucideIcons.imageOff,
color: Colors.white70,
size: 18,
),
),
),
),
),
),
),
);
}
}
class _MediaViewerCircleButton extends StatelessWidget { class _MediaViewerCircleButton extends StatelessWidget {
final IconData icon; final IconData icon;
final String tooltip; final String tooltip;
@@ -240,6 +74,22 @@ class _MediaViewerCircleButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (onPressed != null && defaultTargetPlatform == TargetPlatform.iOS) {
return Theme(
data: ThemeData.dark(),
child: IosGlassNavigationButton(
icon: icon == LucideIcons.x
? IosGlassNavigationIcon.close
: icon == LucideIcons.ellipsis
? IosGlassNavigationIcon.more
: IosGlassNavigationIcon.reply,
semanticLabel: tooltip,
foregroundColor: Colors.white,
controlSize: 48,
onPressed: onPressed,
),
);
}
return SizedBox.square( return SizedBox.square(
dimension: 48, dimension: 48,
child: onPressed == null child: onPressed == null
@@ -287,6 +137,14 @@ class _MediaViewerCloseButton extends StatelessWidget {
} }
} }
EdgeInsets _mediaViewerPadding(BuildContext context) {
final viewPadding = MediaQuery.viewPaddingOf(context);
return EdgeInsets.only(
top: viewPadding.top + 48 + Grid.xxs,
bottom: viewPadding.bottom + 56 + (Grid.xxs * 2),
);
}
Size _imageViewerSize(Size viewport, double? aspectRatio) { Size _imageViewerSize(Size viewport, double? aspectRatio) {
if (aspectRatio == null || aspectRatio <= 0) { if (aspectRatio == null || aspectRatio <= 0) {
return viewport; return viewport;

View File

@@ -1,39 +1,47 @@
part of '../media_viewer_page.dart'; part of '../media_viewer_page.dart';
/// Video transport and message actions, kept visually aligned with the image
/// viewer chrome while the native player owns decoding and playback.
class _VideoViewerBottomControls extends StatelessWidget { class _VideoViewerBottomControls extends StatelessWidget {
final VideoPlayerController? controller;
final VoidCallback? onReply;
const _VideoViewerBottomControls({ const _VideoViewerBottomControls({
required this.controller, required this.controller,
required this.onReply, required this.onReply,
required this.onInteractionStart,
required this.onInteractionEnd,
}); });
final VideoPlayerController? controller;
final VoidCallback? onReply;
final VoidCallback onInteractionStart;
final VoidCallback onInteractionEnd;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final readyController = controller?.value.isInitialized == true final readyController = controller?.value.isInitialized == true
? controller ? controller
: null; : null;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(Grid.sm, Grid.xxs, Grid.sm, 0), padding: const EdgeInsets.fromLTRB(Grid.xs, Grid.xxs, Grid.xs, Grid.xxs),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (readyController != null) if (readyController != null)
_VideoTransportBar(controller: readyController), _VideoTransportBar(
Row( controller: readyController,
children: [ onReply: onReply,
_MediaViewerCircleButton( onInteractionStart: onInteractionStart,
onInteractionEnd: onInteractionEnd,
),
if (readyController == null && onReply != null) ...[
const SizedBox(height: Grid.xxs),
Align(
alignment: AlignmentDirectional.centerStart,
child: _MediaViewerCircleButton(
key: const ValueKey('message-media-video-viewer-reply-thread'), key: const ValueKey('message-media-video-viewer-reply-thread'),
icon: LucideIcons.messageSquareReply, icon: LucideIcons.messageSquareReply,
tooltip: 'Reply in thread', tooltip: 'Reply in thread',
onPressed: onReply, onPressed: onReply,
), ),
const Spacer(), ),
], ],
),
], ],
), ),
); );
@@ -41,9 +49,17 @@ class _VideoViewerBottomControls extends StatelessWidget {
} }
class _VideoTransportBar extends HookConsumerWidget { class _VideoTransportBar extends HookConsumerWidget {
final VideoPlayerController controller; const _VideoTransportBar({
required this.controller,
required this.onReply,
required this.onInteractionStart,
required this.onInteractionEnd,
});
const _VideoTransportBar({required this.controller}); final VideoPlayerController controller;
final VoidCallback? onReply;
final VoidCallback onInteractionStart;
final VoidCallback onInteractionEnd;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -52,78 +68,341 @@ class _VideoTransportBar extends HookConsumerWidget {
final durationMs = value.duration.inMilliseconds; final durationMs = value.duration.inMilliseconds;
final positionMs = value.position.inMilliseconds.clamp(0, durationMs); final positionMs = value.position.inMilliseconds.clamp(0, durationMs);
final hasDuration = durationMs > 0; final hasDuration = durationMs > 0;
final scrubPosition = useState<double?>(null);
final mutedVolume = useRef(1.0);
final scrubGeneration = useRef(0);
return Padding( void beginScrub(double next) {
padding: const EdgeInsets.only(bottom: Grid.xxs), scrubGeneration.value++;
child: DecoratedBox( ScaffoldMessenger.of(context).hideCurrentSnackBar();
decoration: BoxDecoration( onInteractionStart();
color: Colors.black.withValues(alpha: 0.42), scrubPosition.value = next;
border: Border.all(color: Colors.white.withValues(alpha: 0.12)), }
borderRadius: BorderRadius.circular(Radii.full),
), Future<void> finishScrub(double next) async {
child: Padding( final generation = scrubGeneration.value;
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), bool isCurrent() =>
child: Row( context.mounted && generation == scrubGeneration.value;
children: [ try {
IconButton( await controller.seekTo(Duration(milliseconds: next.round()));
key: const ValueKey('message-media-video-viewer-play-pause'), } catch (error) {
debugPrint('[VideoViewer] seek failed: $error');
if (context.mounted && isCurrent()) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Could not seek in this video.'),
action: SnackBarAction(
label: 'Retry',
onPressed: () { onPressed: () {
if (value.isPlaying) { if (!isCurrent()) return;
controller.pause(); beginScrub(next);
} else { unawaited(finishScrub(next));
controller.play();
}
}, },
tooltip: value.isPlaying ? 'Pause video' : 'Play video',
icon: Icon(
value.isPlaying ? LucideIcons.pause : LucideIcons.play,
color: Colors.white,
),
), ),
Text( ),
_formatVideoDuration(Duration(milliseconds: positionMs)), );
style: context.textTheme.labelSmall?.copyWith( }
color: Colors.white, } finally {
fontFeatures: const [FontFeature.tabularFigures()], if (isCurrent()) {
), scrubPosition.value = null;
), onInteractionEnd();
Expanded( }
child: SliderTheme( }
data: SliderTheme.of(context).copyWith( }
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white.withValues(alpha: 0.28), final displayedMs = scrubPosition.value?.round() ?? positionMs;
thumbColor: Colors.white, final currentTime = _formatVideoDuration(
overlayColor: Colors.white.withValues(alpha: 0.12), Duration(milliseconds: displayedMs),
trackHeight: 3, );
thumbShape: const RoundSliderThumbShape( final totalTime = _formatVideoDuration(value.duration);
enabledThumbRadius: 5, final timeStyle = context.textTheme.labelSmall!.copyWith(
), color: Colors.white,
), fontFeatures: const [FontFeature.tabularFigures()],
child: Slider( );
value: hasDuration ? positionMs.toDouble() : 0, final speedStyle = context.textTheme.labelMedium!.copyWith(
min: 0, color: Colors.white,
max: hasDuration ? durationMs.toDouble() : 1, );
onChanged: hasDuration final textScaler = MediaQuery.textScalerOf(context);
? (next) => controller.seekTo( final textDirection = Directionality.of(context);
Duration(milliseconds: next.round()), final labelSizes = useMemoized(() {
) Size measure(String text, TextStyle style) {
: null, final painter = TextPainter(
), text: TextSpan(text: text, style: style),
), textDirection: textDirection,
), textScaler: textScaler,
Text( )..layout();
_formatVideoDuration(value.duration), final size = painter.size;
style: context.textTheme.labelSmall?.copyWith( painter.dispose();
color: Colors.white.withValues(alpha: 0.78), return size;
fontFeatures: const [FontFeature.tabularFigures()], }
),
), return (
const SizedBox(width: Grid.xxs), time: measure(totalTime, timeStyle),
], speed: measure('0.75×', speedStyle),
);
}, [totalTime, timeStyle, speedStyle, textScaler, textDirection]);
final timeline = Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white.withValues(alpha: 0.16),
thumbColor: Colors.white,
overlayColor: Colors.white.withValues(alpha: 0.12),
trackHeight: 4,
thumbShape: const _VideoTimelineThumb(),
trackShape: const RoundedRectSliderTrackShape(),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
),
child: SizedBox(
height: 48,
child: Slider(
key: const ValueKey('message-media-video-viewer-timeline'),
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
label: _formatVideoDuration(Duration(milliseconds: displayedMs)),
semanticFormatterCallback: (next) =>
'${_formatVideoDuration(Duration(milliseconds: next.round()))} of ${_formatVideoDuration(value.duration)}',
value: hasDuration
? displayedMs.clamp(0, durationMs).toDouble()
: 0,
min: 0,
max: hasDuration ? durationMs.toDouble() : 1,
onChangeStart: hasDuration ? beginScrub : null,
onChanged: hasDuration
? (next) {
scrubPosition.value = next;
}
: null,
onChangeEnd: hasDuration
? (next) => unawaited(finishScrub(next))
: null,
), ),
), ),
), ),
); );
final playPause = IconButton(
key: const ValueKey('message-media-video-viewer-play-pause'),
onPressed: () {
onInteractionEnd();
if (value.isPlaying) {
unawaited(controller.pause());
} else {
unawaited(controller.play());
}
},
tooltip: value.isPlaying ? 'Pause video' : 'Play video',
icon: Icon(
value.isPlaying ? LucideIcons.pause : LucideIcons.play,
color: Colors.white,
size: 20,
),
);
const speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
final nextSpeed =
speeds[(speeds.indexOf(value.playbackSpeed) + 1) % speeds.length];
String speedLabel(double speed) =>
'${speed.toString().replaceFirst(RegExp(r'\.0$'), '')}×';
final speed = Tooltip(
message:
'Playback speed: ${speedLabel(value.playbackSpeed)}. Tap for ${speedLabel(nextSpeed)}',
child: TextButton(
key: const ValueKey('message-media-video-viewer-speed'),
onPressed: () {
onInteractionEnd();
unawaited(controller.setPlaybackSpeed(nextSpeed));
},
style: TextButton.styleFrom(
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
),
child: Text(speedLabel(value.playbackSpeed), style: speedStyle),
),
);
final mute = IconButton(
key: const ValueKey('message-media-video-viewer-mute'),
tooltip: value.volume == 0 ? 'Unmute video' : 'Mute video',
onPressed: () {
onInteractionEnd();
if (value.volume > 0) mutedVolume.value = value.volume;
unawaited(
controller.setVolume(value.volume == 0 ? mutedVolume.value : 0),
);
},
icon: Icon(
value.volume == 0 ? LucideIcons.volumeX : LucideIcons.volume2,
color: Colors.white,
size: 20,
),
);
return LayoutBuilder(
builder: (context, constraints) {
final speedWidth = (labelSizes.speed.width + Grid.xxs * 2).clamp(
48.0,
double.infinity,
);
final height = (labelSizes.speed.height + Grid.xxs * 2).clamp(
48.0,
double.infinity,
);
// Keep both timestamps with the timeline. At large accessibility text
// sizes the transport row can scroll without shrinking the text.
final minimumWidth = 48 + 64 + labelSizes.time.width * 2 + Grid.xxs;
final rowWidth = constraints.maxWidth.clamp(
minimumWidth,
double.infinity,
);
final transport = _VideoControlSurface(
child: SizedBox(
width: rowWidth,
height: height,
child: Padding(
padding: const EdgeInsetsDirectional.only(end: Grid.xxs),
child: Row(
children: [
playPause,
Text(
currentTime,
key: const ValueKey(
'message-media-video-viewer-current-time',
),
style: timeStyle,
),
timeline,
Text(
totalTime,
key: const ValueKey(
'message-media-video-viewer-total-time',
),
style: timeStyle.copyWith(color: Colors.white70),
),
],
),
),
),
);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (rowWidth > constraints.maxWidth)
NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification.depth == 0) {
if (notification is ScrollStartNotification) {
onInteractionStart();
} else if (notification is ScrollEndNotification) {
onInteractionEnd();
}
}
return false;
},
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: transport,
),
)
else
transport,
const SizedBox(height: Grid.xxs),
Row(
children: [
if (onReply != null)
_MediaViewerCircleButton(
key: const ValueKey(
'message-media-video-viewer-reply-thread',
),
icon: LucideIcons.messageSquareReply,
tooltip: 'Reply in thread',
onPressed: onReply,
),
const Spacer(),
_VideoControlSurface(
child: SizedBox(
width: speedWidth,
height: height,
child: speed,
),
),
const SizedBox(width: Grid.xxs),
_VideoControlSurface(
child: SizedBox(width: 48, height: height, child: mute),
),
],
),
],
);
},
);
}
}
class _VideoControlSurface extends StatelessWidget {
const _VideoControlSurface({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
if (defaultTargetPlatform == TargetPlatform.iOS) {
return Theme(
data: ThemeData.dark(),
child: ConcentricSheetSurface(
enabled: true,
usesGlass: true,
providesSheetSurface: false,
padding: EdgeInsets.zero,
minimumRadius: Radii.dialog,
contentClipRadius: Radii.dialog,
color: Colors.black.withValues(alpha: 0.35),
child: child,
),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(Radii.dialog),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.35),
border: Border.all(color: Colors.white.withValues(alpha: 0.10)),
borderRadius: BorderRadius.circular(Radii.dialog),
),
child: child,
),
),
);
}
}
/// Desktop's narrow vertical playhead, with the slider retaining a full-height
/// touch target independently of this visual marker.
class _VideoTimelineThumb extends SliderComponentShape {
const _VideoTimelineThumb();
@override
Size getPreferredSize(bool isEnabled, bool isDiscrete) => const Size(2, 16);
@override
void paint(
PaintingContext context,
Offset center, {
required Animation<double> activationAnimation,
required Animation<double> enableAnimation,
required bool isDiscrete,
required TextPainter labelPainter,
required RenderBox parentBox,
required SliderThemeData sliderTheme,
required TextDirection textDirection,
required double value,
required double textScaleFactor,
required Size sizeWithOverflow,
}) {
context.canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromCenter(center: center, width: 2, height: 16),
const Radius.circular(1),
),
Paint()..color = Colors.white,
);
} }
} }
@@ -136,5 +415,5 @@ String _formatVideoDuration(Duration duration) {
if (hours > 0) { if (hours > 0) {
return '$hours:${minutes.toString().padLeft(2, '0')}:$paddedSeconds'; return '$hours:${minutes.toString().padLeft(2, '0')}:$paddedSeconds';
} }
return '$minutes:$paddedSeconds'; return '${minutes.toString().padLeft(2, '0')}:$paddedSeconds';
} }

View File

@@ -0,0 +1,113 @@
part of '../media_viewer_page.dart';
/// Only playback transitions affect visibility; position ticks must never
/// restart the idle timer or force the entire video route to rebuild.
({
bool visible,
VoidCallback toggle,
VoidCallback beginInteraction,
VoidCallback endInteraction,
})
_useVideoControlsVisibility(
BuildContext context,
VideoPlayerController? controller,
) {
final visible = useState(true);
final playing = useState(false);
final interacting = useState(false);
final interactionVersion = useState(0);
final lifecycle = useAppLifecycleState();
final accessibleNavigation = MediaQuery.accessibleNavigationOf(context);
final isCurrentRoute = ModalRoute.of(context)?.isCurrent ?? true;
useEffect(() {
void updatePlayback() {
final value = controller?.value;
final active =
value != null &&
value.isInitialized &&
value.isPlaying &&
!value.isBuffering &&
!value.hasError;
if (playing.value == active) return;
playing.value = active;
if (!active) visible.value = true;
}
updatePlayback();
controller?.addListener(updatePlayback);
return () => controller?.removeListener(updatePlayback);
}, [controller]);
useEffect(
() {
if (!visible.value ||
!playing.value ||
interacting.value ||
accessibleNavigation ||
!isCurrentRoute ||
(lifecycle != null && lifecycle != AppLifecycleState.resumed)) {
return null;
}
final timer = Timer(
const Duration(seconds: 3),
() => visible.value = false,
);
return timer.cancel;
},
[
visible.value,
playing.value,
interacting.value,
interactionVersion.value,
accessibleNavigation,
isCurrentRoute,
lifecycle,
],
);
return (
visible: visible.value,
toggle: () => visible.value = !visible.value,
beginInteraction: () {
interacting.value = true;
visible.value = true;
},
endInteraction: () {
interacting.value = false;
interactionVersion.value++;
},
);
}
class _VideoViewerChrome extends StatelessWidget {
const _VideoViewerChrome({
super.key,
required this.visible,
required this.dragOpacity,
required this.child,
});
final bool visible;
final double dragOpacity;
final Widget child;
@override
Widget build(BuildContext context) {
final interactive = visible && dragOpacity > 0;
return IgnorePointer(
ignoring: !interactive,
child: ExcludeSemantics(
excluding: !interactive,
child: AnimatedOpacity(
opacity: visible ? dragOpacity : 0,
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
child: child,
),
),
);
}
}

View File

@@ -280,6 +280,8 @@ class MediaVideoViewerPage extends HookConsumerWidget {
callback(); callback();
} }
final controls = _useVideoControlsVisibility(context, controller.value);
final viewportHeight = MediaQuery.sizeOf(context).height; final viewportHeight = MediaQuery.sizeOf(context).height;
final dragProgress = (dragOffset.value / viewportHeight).clamp(0.0, 1.0); final dragProgress = (dragOffset.value / viewportHeight).clamp(0.0, 1.0);
final videoScale = 1 - (dragProgress * 0.1); final videoScale = 1 - (dragProgress * 0.1);
@@ -299,22 +301,24 @@ class MediaVideoViewerPage extends HookConsumerWidget {
offset: Offset(0, dragOffset.value), offset: Offset(0, dragOffset.value),
child: Transform.scale( child: Transform.scale(
scale: videoScale, scale: videoScale,
child: GestureDetector( child: _VideoZoomSurface(
key: const ValueKey('message-media-video-viewer-gesture'), key: ValueKey(videoUrl),
behavior: HitTestBehavior.opaque, onTap: controls.toggle,
onVerticalDragStart: (_) { controlsVisible: controls.visible,
onInteractionStart: controls.beginInteraction,
onInteractionEnd: controls.endInteraction,
onDismissStart: () {
snapBackController.stop(); snapBackController.stop();
isDragging.value = true; isDragging.value = true;
}, },
onVerticalDragUpdate: (details) { onDismissUpdate: (delta) {
if (!isDragging.value) return; if (!isDragging.value) return;
dragOffset.value = (dragOffset.value + details.delta.dy) dragOffset.value = (dragOffset.value + delta)
.clamp(0.0, viewportHeight) .clamp(0.0, viewportHeight)
.toDouble(); .toDouble();
}, },
onVerticalDragEnd: (details) { onDismissEnd: (velocity) {
isDragging.value = false; isDragging.value = false;
final velocity = details.primaryVelocity ?? 0;
if (dragOffset.value > _dismissThreshold || if (dragOffset.value > _dismissThreshold ||
velocity > _dismissVelocity) { velocity > _dismissVelocity) {
controller.value?.pause(); controller.value?.pause();
@@ -323,8 +327,13 @@ class MediaVideoViewerPage extends HookConsumerWidget {
} }
animateSnapBack(); animateSnapBack();
}, },
onVerticalDragCancel: animateSnapBack, onDismissCancel: () {
child: SafeArea( snapBackController.stop();
isDragging.value = false;
dragOffset.value = 0;
},
child: Padding(
padding: _mediaViewerPadding(context),
child: Center( child: Center(
child: FutureBuilder<void>( child: FutureBuilder<void>(
future: initializeFuture.value, future: initializeFuture.value,
@@ -362,8 +371,9 @@ class MediaVideoViewerPage extends HookConsumerWidget {
height: height:
MediaQuery.paddingOf(context).top + MediaQuery.paddingOf(context).top +
IosNavigationMetrics.of(context).compactHeight, IosNavigationMetrics.of(context).compactHeight,
child: Opacity( child: _VideoViewerChrome(
opacity: chromeOpacity, visible: controls.visible,
dragOpacity: chromeOpacity,
child: Theme( child: Theme(
data: ThemeData.dark(), data: ThemeData.dark(),
child: IosNavigationBar( child: IosNavigationBar(
@@ -383,8 +393,9 @@ class MediaVideoViewerPage extends HookConsumerWidget {
PositionedDirectional( PositionedDirectional(
top: Grid.sm, top: Grid.sm,
end: Grid.sm, end: Grid.sm,
child: Opacity( child: _VideoViewerChrome(
opacity: chromeOpacity, visible: controls.visible,
dragOpacity: chromeOpacity,
child: SafeArea( child: SafeArea(
child: _MediaViewerCloseButton( child: _MediaViewerCloseButton(
key: const ValueKey('message-media-video-viewer-close'), key: const ValueKey('message-media-video-viewer-close'),
@@ -398,11 +409,15 @@ class MediaVideoViewerPage extends HookConsumerWidget {
bottom: 0, bottom: 0,
start: 0, start: 0,
end: 0, end: 0,
child: Opacity( child: _VideoViewerChrome(
opacity: chromeOpacity, key: const ValueKey('message-media-video-viewer-controls'),
visible: controls.visible,
dragOpacity: chromeOpacity,
child: SafeArea( child: SafeArea(
child: _VideoViewerBottomControls( child: _VideoViewerBottomControls(
controller: controller.value, controller: controller.value,
onInteractionStart: controls.beginInteraction,
onInteractionEnd: controls.endInteraction,
onReply: onReply == null onReply: onReply == null
? null ? null
: () => unawaited(replyInThread()), : () => unawaited(replyInThread()),

View File

@@ -0,0 +1,148 @@
part of '../media_viewer_page.dart';
class _VideoZoomSurface extends HookWidget {
const _VideoZoomSurface({
super.key,
required this.child,
required this.onTap,
required this.controlsVisible,
required this.onInteractionStart,
required this.onInteractionEnd,
required this.onDismissStart,
required this.onDismissUpdate,
required this.onDismissEnd,
required this.onDismissCancel,
});
final Widget child;
final VoidCallback onTap;
final bool controlsVisible;
final VoidCallback onInteractionStart;
final VoidCallback onInteractionEnd;
final VoidCallback onDismissStart;
final ValueChanged<double> onDismissUpdate;
final ValueChanged<double> onDismissEnd;
final VoidCallback onDismissCancel;
@override
Widget build(BuildContext context) {
final transform = useTransformationController();
final transformed = useState(false);
final dismissGesture = useRef(false);
final resetStart = useRef(Matrix4.identity());
final gestureGeneration = useState(0);
final resetRequest = useRef(0);
final reset = useAnimationController(
duration: const Duration(milliseconds: 180),
);
useEffect(() {
void updateTransform() {
transformed.value = _hasImageTransform(transform.value);
if (transformed.value && dismissGesture.value) {
dismissGesture.value = false;
onDismissCancel();
}
}
void updateReset() {
transform.value = Matrix4Tween(
begin: resetStart.value,
end: Matrix4.identity(),
).transform(Curves.easeOutCubic.transform(reset.value));
}
transform.addListener(updateTransform);
reset.addListener(updateReset);
return () {
transform.removeListener(updateTransform);
reset.removeListener(updateReset);
};
}, [transform, reset, onDismissCancel]);
void resetToFit({bool cancelMomentum = false}) {
final request = ++resetRequest.value;
reset.stop();
dismissGesture.value = false;
onDismissCancel();
resetStart.value = Matrix4.copy(transform.value);
void startReset() {
if (!context.mounted || resetRequest.value != request) return;
if (MediaQuery.disableAnimationsOf(context)) {
transform.value = Matrix4.identity();
} else {
reset.forward(from: 0);
}
}
if (cancelMomentum) {
// Dispose the viewer's internal fling animation so it cannot keep
// moving the shared transform after the double-tap reset.
gestureGeneration.value++;
// Wait for that old viewer to dispose before writing the reset,
// including when reduced motion makes the reset instantaneous.
WidgetsBinding.instance.addPostFrameCallback((_) => startReset());
} else {
startReset();
}
}
return Semantics(
button: true,
label: controlsVisible ? 'Hide video controls' : 'Show video controls',
onTap: onTap,
child: GestureDetector(
excludeFromSemantics: true,
key: const ValueKey('message-media-video-viewer-gesture'),
behavior: HitTestBehavior.opaque,
onTap: onTap,
onDoubleTap: transformed.value
? () {
onInteractionEnd();
resetToFit(cancelMomentum: true);
}
: null,
child: KeyedSubtree(
key: ValueKey(gestureGeneration.value),
child: InteractiveViewer(
key: const ValueKey('message-media-video-viewer-zoom'),
transformationController: transform,
minScale: 1,
maxScale: 4,
panEnabled: transformed.value,
boundaryMargin: const EdgeInsets.all(Grid.xxl),
clipBehavior: Clip.none,
onInteractionStart: (details) {
resetRequest.value++;
reset.stop();
onInteractionStart();
dismissGesture.value =
details.pointerCount == 1 && !transformed.value;
if (dismissGesture.value) onDismissStart();
},
onInteractionUpdate: (details) {
if (details.pointerCount > 1 || details.scale != 1) {
if (dismissGesture.value) onDismissCancel();
dismissGesture.value = false;
} else if (dismissGesture.value && !transformed.value) {
onDismissUpdate(details.focalPointDelta.dy);
}
},
onInteractionEnd: (details) {
onInteractionEnd();
if (dismissGesture.value) {
dismissGesture.value = false;
onDismissEnd(details.velocity.pixelsPerSecond.dy);
} else if (transform.value.getMaxScaleOnAxis() <= 1.001) {
// Finish a pinch back to fit at the original centered position,
// so the next downward swipe can dismiss rather than pan.
resetToFit();
}
},
child: child,
),
),
),
);
}
}

View File

@@ -275,90 +275,93 @@ void showImageActions({
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
showDragHandle: true, showDragHandle: true,
title: 'Image',
builder: (sheetContext) => SafeArea( builder: (sheetContext) => SafeArea(
child: IconTheme.merge( child: SingleChildScrollView(
data: const IconThemeData(size: 22), child: IconTheme.merge(
child: Padding( data: const IconThemeData(size: 22),
padding: const EdgeInsets.fromLTRB( child: Padding(
Grid.gutter, padding: const EdgeInsets.fromLTRB(
0, Grid.gutter,
Grid.gutter, 0,
Grid.xs, Grid.gutter,
), Grid.xs,
child: Column( ),
mainAxisSize: MainAxisSize.min, child: Column(
children: [ mainAxisSize: MainAxisSize.min,
SheetActionSection( children: [
children: [
ListTile(
leading: const Icon(LucideIcons.download),
title: const Text('Save image'),
onTap: () {
Navigator.of(sheetContext).pop();
unawaited(_saveImage(context, ref, imageUrl));
},
),
ListTile(
leading: const Icon(LucideIcons.share2),
title: const Text('Share image'),
onTap: () {
final renderBox =
context.findRenderObject() as RenderBox?;
final shareOrigin = renderBox == null
? null
: renderBox.localToGlobal(Offset.zero) &
renderBox.size;
Navigator.of(sheetContext).pop();
unawaited(
_shareImage(
context,
ref,
imageUrl,
shareOrigin: shareOrigin,
),
);
},
),
ListTile(
leading: const Icon(LucideIcons.link2),
title: const Text('Copy image link'),
onTap: () {
Navigator.of(sheetContext).pop();
copyToClipboard(
context,
imageUrl,
message: 'Image link copied',
);
},
),
],
),
if (canManageMessage)
SheetActionSection( SheetActionSection(
children: [ children: [
ListTile( ListTile(
leading: Icon( leading: const Icon(LucideIcons.download),
LucideIcons.trash2, title: const Text('Save image'),
color: sheetContext.colors.error,
),
title: Text(
'Delete message',
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () { onTap: () {
Navigator.of(sheetContext).pop(); Navigator.of(sheetContext).pop();
_confirmDelete( unawaited(_saveImage(context, ref, imageUrl));
context: context, },
ref: ref, ),
channelId: channelId, ListTile(
messageId: message.id, leading: const Icon(LucideIcons.share2),
onDeleted: onDeleted, title: const Text('Share image'),
onTap: () {
final renderBox =
context.findRenderObject() as RenderBox?;
final shareOrigin = renderBox == null
? null
: renderBox.localToGlobal(Offset.zero) &
renderBox.size;
Navigator.of(sheetContext).pop();
unawaited(
_shareImage(
context,
ref,
imageUrl,
shareOrigin: shareOrigin,
),
);
},
),
ListTile(
leading: const Icon(LucideIcons.link2),
title: const Text('Copy image link'),
onTap: () {
Navigator.of(sheetContext).pop();
copyToClipboard(
context,
imageUrl,
message: 'Image link copied',
); );
}, },
), ),
], ],
), ),
], if (canManageMessage)
SheetActionSection(
children: [
ListTile(
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
'Delete message',
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(
context: context,
ref: ref,
channelId: channelId,
messageId: message.id,
onDeleted: onDeleted,
);
},
),
],
),
],
),
), ),
), ),
), ),

View File

@@ -123,7 +123,6 @@ class _MessageImageCarousel extends HookConsumerWidget {
() => [for (var index = 0; index < items.length; index++) Object()], () => [for (var index = 0; index < items.length; index++) Object()],
[itemSignature], [itemSignature],
); );
final controller = usePageController(viewportFraction: 0.9);
final currentIndex = useState(0); final currentIndex = useState(0);
final mediaAuth = ref.watch(mediaGetAuthServiceProvider); final mediaAuth = ref.watch(mediaGetAuthServiceProvider);
final mediaClient = ref.watch(mediaHttpClientProvider); final mediaClient = ref.watch(mediaHttpClientProvider);
@@ -161,8 +160,7 @@ class _MessageImageCarousel extends HookConsumerWidget {
for (var index = 0; index < items.length; index++) for (var index = 0; index < items.length; index++)
math.max( math.max(
1.0, 1.0,
carouselWidth * controller.viewportFraction - carouselWidth * 0.9 - itemTrailingPaddings[index],
itemTrailingPaddings[index],
), ),
]; ];
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
@@ -193,16 +191,16 @@ class _MessageImageCarousel extends HookConsumerWidget {
key: const ValueKey('message-media-carousel'), key: const ValueKey('message-media-carousel'),
width: carouselWidth, width: carouselWidth,
height: _messageMediaCarouselHeight, height: _messageMediaCarouselHeight,
child: PageView.builder( child: _MessageCarouselViewport(
controller: controller, width: carouselWidth,
allowImplicitScrolling: true, currentIndex: currentIndex.value,
// Keep the first image aligned with the message body, but
// allow later pages to paint through the avatar gutter as
// the row scrolls.
clipBehavior: Clip.none,
padEnds: false,
itemCount: items.length, itemCount: items.length,
onPageChanged: (index) => currentIndex.value = index, onPageChanged: (index) {
if (index != currentIndex.value) {
unawaited(HapticFeedback.selectionClick());
}
currentIndex.value = index;
},
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = items[index]; final item = items[index];
return Padding( return Padding(
@@ -301,3 +299,80 @@ class _MessageImageCarousel extends HookConsumerWidget {
); );
} }
} }
/// Extends the actual paint viewport through the message gutters. Clip.none
/// alone cannot keep a sliver child painting once it leaves its viewport.
class _MessageCarouselViewport extends HookWidget {
const _MessageCarouselViewport({
required this.width,
required this.currentIndex,
required this.itemCount,
required this.onPageChanged,
required this.itemBuilder,
});
final double width;
final int currentIndex;
final int itemCount;
final ValueChanged<int> onPageChanged;
final IndexedWidgetBuilder itemBuilder;
@override
Widget build(BuildContext context) {
// Cover any space between the message body and either screen edge.
// Matching sliver padding preserves the original alignment, page stride,
// and end-of-gallery scroll limit despite the wider paint viewport.
final gutter = math.max(0.0, MediaQuery.sizeOf(context).width - width);
final viewportWidth = width + 2 * gutter;
final fraction = width * 0.9 / viewportWidth;
final controller = usePageController(
initialPage: currentIndex,
viewportFraction: fraction,
keys: [fraction],
);
return OverflowBox(
minWidth: viewportWidth,
maxWidth: viewportWidth,
child: NotificationListener<ScrollUpdateNotification>(
onNotification: (notification) {
if (notification.depth == 0 && notification.metrics is PageMetrics) {
final index = (notification.metrics as PageMetrics).page!.round();
if (index != currentIndex) onPageChanged(index);
}
return false;
},
child: CustomScrollView(
controller: controller,
scrollDirection: Axis.horizontal,
physics: const _CarouselPagePhysics(),
clipBehavior: Clip.none,
slivers: [
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverFillViewport(
viewportFraction: fraction,
padEnds: false,
delegate: SliverChildBuilderDelegate(
itemBuilder,
childCount: itemCount,
),
),
),
],
),
),
);
}
}
class _CarouselPagePhysics extends PageScrollPhysics {
const _CarouselPagePhysics({super.parent});
@override
bool get allowImplicitScrolling => true;
@override
_CarouselPagePhysics applyTo(ScrollPhysics? ancestor) =>
_CarouselPagePhysics(parent: buildParent(ancestor));
}

View File

@@ -15,7 +15,7 @@ import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/ios_glass_navigation_action.dart'; import '../../shared/widgets/ios_glass_navigation_action.dart';
import '../../shared/widgets/ios_glass_navigation_button.dart'; import '../../shared/widgets/ios_glass_navigation_button.dart';
import '../../shared/widgets/ios_glass_theme_pagination.dart'; import '../../shared/widgets/page_indicator.dart';
import 'theme_option_sheets.dart'; import 'theme_option_sheets.dart';
part 'theme_picker_page/theme_home_preview.dart'; part 'theme_picker_page/theme_home_preview.dart';

View File

@@ -3,7 +3,6 @@ part of '../theme_picker_page.dart';
const _appearanceOrder = [ThemeMode.light, ThemeMode.dark, ThemeMode.system]; const _appearanceOrder = [ThemeMode.light, ThemeMode.dark, ThemeMode.system];
const _appearanceMotionDuration = Duration(milliseconds: 150); const _appearanceMotionDuration = Duration(milliseconds: 150);
const _appearanceMotionCurve = Cubic(0.23, 1, 0.32, 1); const _appearanceMotionCurve = Cubic(0.23, 1, 0.32, 1);
const _iosThemeScrubberWidth = 116.0;
class _ThemePreviewExperience extends HookConsumerWidget { class _ThemePreviewExperience extends HookConsumerWidget {
const _ThemePreviewExperience(); const _ThemePreviewExperience();
@@ -231,7 +230,10 @@ class _ThemePreviewExperience extends HookConsumerWidget {
), ),
), ),
Expanded( Expanded(
child: _ThemeScrubber( child: PageIndicator(
semanticLabel: 'Theme',
controlKey: const ValueKey('theme-preview-scrubber'),
dotKeyPrefix: 'theme-pagination-dot-',
count: entries.length, count: entries.length,
selected: currentPage.value.clamp(0, entries.length - 1), selected: currentPage.value.clamp(0, entries.length - 1),
animateChanges: animatePaginationChanges, animateChanges: animatePaginationChanges,
@@ -627,196 +629,3 @@ class _AppearanceCycleAction extends StatelessWidget {
); );
} }
} }
class _ThemeScrubber extends StatelessWidget {
const _ThemeScrubber({
required this.count,
required this.selected,
required this.animateChanges,
required this.onSelected,
});
final int count;
final int selected;
final bool animateChanges;
final ValueChanged<int> onSelected;
@override
Widget build(BuildContext context) {
void selectFromPosition(double dx, double width) {
if (width <= 0 || count <= 1) return;
final index = ((dx / width) * count).floor().clamp(0, count - 1);
onSelected(index);
}
return Semantics(
label: 'Theme ${selected + 1} of $count',
slider: true,
value: '${selected + 1}',
increasedValue: selected < count - 1 ? '${selected + 2}' : null,
decreasedValue: selected > 0 ? '$selected' : null,
onIncrease: selected < count - 1 ? () => onSelected(selected + 1) : null,
onDecrease: selected > 0 ? () => onSelected(selected - 1) : null,
child: SizedBox(
key: const ValueKey('theme-preview-scrubber'),
height: 54,
child: defaultTargetPlatform == TargetPlatform.iOS
? Center(
child: SizedBox(
width: _iosThemeScrubberWidth,
child: IosGlassThemePagination(
count: count,
selected: selected,
animateChanges: animateChanges,
onSelected: onSelected,
activeColor: context.colors.onSurface,
inactiveColor: context.colors.onSurfaceVariant.withValues(
alpha: 0.32,
),
),
),
)
: LayoutBuilder(
builder: (context, constraints) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (details) => selectFromPosition(
details.localPosition.dx,
constraints.maxWidth,
),
onHorizontalDragStart: (details) => selectFromPosition(
details.localPosition.dx,
constraints.maxWidth,
),
onHorizontalDragUpdate: (details) => selectFromPosition(
details.localPosition.dx,
constraints.maxWidth,
),
child: Center(
child: SizedBox(
width: _iosThemeScrubberWidth,
child: _WindowedThemePagination(
count: count,
selected: selected,
animateChanges: animateChanges,
),
),
),
),
),
),
);
}
}
class _WindowedThemePagination extends StatelessWidget {
const _WindowedThemePagination({
required this.count,
required this.selected,
required this.animateChanges,
});
static const _maximumVisibleDots = 7;
static const _dotSize = 6.0;
static const _selectedDotSize = 10.0;
static const _spacing = 6.0;
final int count;
final int selected;
final bool animateChanges;
@override
Widget build(BuildContext context) {
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final duration = reduceMotion || !animateChanges
? Duration.zero
: const Duration(milliseconds: 150);
final visibleCount = count.clamp(1, _maximumVisibleDots);
final maximumStart = (count - visibleCount).clamp(0, count);
final centerSlot = visibleCount ~/ 2;
final windowStart = (selected - centerSlot).clamp(0, maximumStart);
final windowEnd = windowStart + visibleCount - 1;
final hasEarlierDots = windowStart > 0;
final hasLaterDots = windowEnd < count - 1;
return Container(
height: 30,
padding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.full),
),
child: LayoutBuilder(
builder: (context, constraints) {
final pitch = _dotSize + _spacing;
final trackWidth =
visibleCount * _dotSize + (visibleCount - 1) * _spacing;
final trackOrigin = (constraints.maxWidth - trackWidth) / 2;
return ClipRect(
child: Stack(
children: [
for (var page = 0; page < count; page++)
_buildDot(
context: context,
page: page,
slot: page - windowStart,
visibleCount: visibleCount,
trackOrigin: trackOrigin,
pitch: pitch,
hasEarlierDots: hasEarlierDots,
hasLaterDots: hasLaterDots,
duration: duration,
),
],
),
);
},
),
);
}
Widget _buildDot({
required BuildContext context,
required int page,
required int slot,
required int visibleCount,
required double trackOrigin,
required double pitch,
required bool hasEarlierDots,
required bool hasLaterDots,
required Duration duration,
}) {
final isVisible = slot >= 0 && slot < visibleCount;
final diameter = page == selected
? _selectedDotSize
: (hasEarlierDots && slot == 0) ||
(hasLaterDots && slot == visibleCount - 1)
? 2.0
: (hasEarlierDots && slot == 1) ||
(hasLaterDots && slot == visibleCount - 2)
? 4.0
: _dotSize;
final centerX = trackOrigin + _dotSize / 2 + slot * pitch;
return AnimatedPositioned(
key: ValueKey('theme-pagination-dot-$page'),
duration: duration,
curve: Curves.easeInOutCubic,
left: centerX - diameter / 2,
top: (30 - diameter) / 2,
width: diameter,
height: diameter,
child: AnimatedOpacity(
duration: duration,
opacity: isVisible ? 1 : 0,
child: DecoratedBox(
decoration: BoxDecoration(
color: page == selected
? context.colors.onSurface
: context.colors.onSurfaceVariant.withValues(alpha: 0.32),
shape: BoxShape.circle,
),
),
),
);
}
}

View File

@@ -596,6 +596,8 @@ IosNavigationAction? _nativeAction(Widget? widget) {
} }
if (widget is IosGlassNavigationButton) { if (widget is IosGlassNavigationButton) {
final symbol = switch (widget.icon) { final symbol = switch (widget.icon) {
IosGlassNavigationIcon.more => 'ellipsis',
IosGlassNavigationIcon.reply => 'arrowshape.turn.up.left',
IosGlassNavigationIcon.back => 'chevron.backward', IosGlassNavigationIcon.back => 'chevron.backward',
IosGlassNavigationIcon.close => 'xmark', IosGlassNavigationIcon.close => 'xmark',
IosGlassNavigationIcon.camera => 'camera', IosGlassNavigationIcon.camera => 'camera',

View File

@@ -25,6 +25,8 @@ enum IosGlassNavigationIcon {
sun, sun,
moon, moon,
systemAppearance, systemAppearance,
more,
reply,
} }
/// Leading width used by iOS channel-style headers. /// Leading width used by iOS channel-style headers.
@@ -226,6 +228,8 @@ class IosGlassNavigationButton extends HookWidget {
) )
: Icon( : Icon(
switch (icon) { switch (icon) {
IosGlassNavigationIcon.more => Icons.more_horiz,
IosGlassNavigationIcon.reply => Icons.reply,
IosGlassNavigationIcon.back => IosGlassNavigationIcon.back =>
Icons.arrow_back_ios_new_rounded, Icons.arrow_back_ios_new_rounded,
IosGlassNavigationIcon.close => IosGlassNavigationIcon.close =>

View File

@@ -11,6 +11,8 @@ class IosGlassThemePagination extends HookWidget {
/// Creates a native theme-pagination control. /// Creates a native theme-pagination control.
const IosGlassThemePagination({ const IosGlassThemePagination({
super.key, super.key,
this.semanticLabel = 'Theme',
this.containerHeight = 30,
required this.count, required this.count,
required this.selected, required this.selected,
required this.animateChanges, required this.animateChanges,
@@ -21,6 +23,12 @@ class IosGlassThemePagination extends HookWidget {
static const viewType = 'buzz/theme_pagination_glass'; static const viewType = 'buzz/theme_pagination_glass';
/// The kind of content being paged, announced by VoiceOver.
final String semanticLabel;
/// Height of the glass pill, independent of its dots and hit target.
final double containerHeight;
final int count; final int count;
final int selected; final int selected;
final bool animateChanges; final bool animateChanges;
@@ -33,6 +41,7 @@ class IosGlassThemePagination extends HookWidget {
assert(defaultTargetPlatform == TargetPlatform.iOS); assert(defaultTargetPlatform == TargetPlatform.iOS);
final nativeChannel = useState<MethodChannel?>(null); final nativeChannel = useState<MethodChannel?>(null);
final onSelectedRef = useRef(onSelected)..value = onSelected; final onSelectedRef = useRef(onSelected)..value = onSelected;
final isRTL = Directionality.of(context) == TextDirection.rtl;
final brightness = Theme.of(context).brightness.name; final brightness = Theme.of(context).brightness.name;
final activeColorValue = activeColor.toARGB32(); final activeColorValue = activeColor.toARGB32();
final inactiveColorValue = inactiveColor.toARGB32(); final inactiveColorValue = inactiveColor.toARGB32();
@@ -54,6 +63,9 @@ class IosGlassThemePagination extends HookWidget {
if (channel != null) { if (channel != null) {
unawaited( unawaited(
channel.invokeMethod<void>('setState', <String, Object>{ channel.invokeMethod<void>('setState', <String, Object>{
'accessibilityLabel': semanticLabel,
'isRTL': isRTL,
'containerHeight': containerHeight,
'count': count, 'count': count,
'selected': selected, 'selected': selected,
'animateChanges': animateChanges, 'animateChanges': animateChanges,
@@ -67,6 +79,9 @@ class IosGlassThemePagination extends HookWidget {
}, },
[ [
nativeChannel.value, nativeChannel.value,
semanticLabel,
isRTL,
containerHeight,
count, count,
selected, selected,
animateChanges, animateChanges,
@@ -80,6 +95,9 @@ class IosGlassThemePagination extends HookWidget {
viewType: viewType, viewType: viewType,
hitTestBehavior: PlatformViewHitTestBehavior.opaque, hitTestBehavior: PlatformViewHitTestBehavior.opaque,
creationParams: <String, Object>{ creationParams: <String, Object>{
'accessibilityLabel': semanticLabel,
'isRTL': isRTL,
'containerHeight': containerHeight,
'count': count, 'count': count,
'selected': selected, 'selected': selected,
'animateChanges': animateChanges, 'animateChanges': animateChanges,

View File

@@ -0,0 +1,273 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../theme/theme.dart';
import 'ios_glass_theme_pagination.dart';
/// Appearance-style pagination with native Liquid Glass on iOS.
class PageIndicator extends StatelessWidget {
/// Creates a tappable and scrubbable page indicator.
const PageIndicator({
super.key,
required this.semanticLabel,
this.controlKey,
this.dotKeyPrefix = 'page-indicator-dot-',
this.containerHeight = 30,
required this.count,
required this.selected,
required this.animateChanges,
required this.onSelected,
});
/// The content name announced with the current page.
final String semanticLabel;
/// Optional key for the interactive control.
final Key? controlKey;
/// Key prefix identifying the individual page dots.
final String dotKeyPrefix;
/// Height of the pill without changing the dot sizes.
final double containerHeight;
/// Total number of pages.
final int count;
/// Zero-based current page.
final int selected;
/// Whether dot position changes animate.
final bool animateChanges;
/// Called when a page is selected by tap, scrub, or accessibility.
final ValueChanged<int> onSelected;
@override
Widget build(BuildContext context) {
final native = defaultTargetPlatform == TargetPlatform.iOS;
final control = SizedBox(
key: controlKey,
height: 54,
child: Center(
child: SizedBox(
width: 116,
child: native
? IosGlassThemePagination(
containerHeight: containerHeight,
semanticLabel: semanticLabel,
count: count,
selected: selected,
animateChanges: animateChanges,
onSelected: onSelected,
activeColor: context.colors.onSurface,
inactiveColor: context.colors.onSurfaceVariant.withValues(
alpha: 0.32,
),
)
: _WindowedPagination(
containerHeight: containerHeight,
dotKeyPrefix: dotKeyPrefix,
count: count,
selected: selected,
animateChanges: animateChanges,
onSelected: onSelected,
),
),
),
);
// UIKit owns its adjustable element and bridges it through UiKitView.
if (native) return control;
return Semantics(
label: '$semanticLabel ${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: control,
);
}
}
typedef _PaginationScrubGeometry = ({
int windowStart,
int visibleCount,
double width,
bool isRtl,
});
class _WindowedPagination extends HookWidget {
const _WindowedPagination({
required this.containerHeight,
required this.dotKeyPrefix,
required this.count,
required this.selected,
required this.animateChanges,
required this.onSelected,
});
final String dotKeyPrefix;
final double containerHeight;
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;
final ValueChanged<int> onSelected;
@override
Widget build(BuildContext context) {
final scrubGeometry = useState<_PaginationScrubGeometry?>(null);
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final duration = reduceMotion || !animateChanges
? Duration.zero
: const Duration(milliseconds: 150);
final currentVisibleCount = count.clamp(1, _maximumVisibleDots);
final maximumStart = (count - currentVisibleCount).clamp(0, count);
final centerSlot = currentVisibleCount ~/ 2;
return LayoutBuilder(
builder: (context, constraints) {
final geometry =
scrubGeometry.value ??
(
windowStart: (selected - centerSlot).clamp(0, maximumStart),
visibleCount: currentVisibleCount,
width: constraints.maxWidth,
isRtl: Directionality.of(context) == TextDirection.rtl,
);
final windowStart = geometry.windowStart;
final visibleCount = geometry.visibleCount;
final isRtl = geometry.isRtl;
final windowEnd = windowStart + visibleCount - 1;
final hasEarlierDots = windowStart > 0;
final hasLaterDots = windowEnd < count - 1;
final pitch = _dotSize + _spacing;
final trackWidth =
visibleCount * _dotSize + (visibleCount - 1) * _spacing;
void selectFromPosition(double dx) {
if (count <= 1) return;
final firstCenter = (geometry.width - trackWidth) / 2 + _dotSize / 2;
final slot = ((dx - firstCenter) / pitch).round().clamp(
0,
visibleCount - 1,
);
final page = windowStart + (isRtl ? visibleCount - 1 - slot : slot);
onSelected(page);
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
// A tap must not select on pointer-down before a drag can snapshot
// the window. Otherwise the first drag callback already sees a shift.
onTapUp: (details) => selectFromPosition(details.localPosition.dx),
onHorizontalDragStart: (details) {
scrubGeometry.value = geometry;
selectFromPosition(details.localPosition.dx);
},
onHorizontalDragUpdate: (details) =>
selectFromPosition(details.localPosition.dx),
onHorizontalDragEnd: (_) => scrubGeometry.value = null,
onHorizontalDragCancel: () => scrubGeometry.value = null,
child: SizedBox(
height: 54,
child: Center(
child: Container(
height: containerHeight,
padding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.full),
),
child: LayoutBuilder(
builder: (context, constraints) {
final trackOrigin =
(geometry.width - Grid.twelve * 2 - trackWidth) / 2;
return ClipRect(
child: Stack(
children: [
for (var page = 0; page < count; page++)
_buildDot(
context: context,
page: page,
slot: isRtl
? visibleCount - 1 - (page - windowStart)
: page - windowStart,
visibleCount: visibleCount,
trackOrigin: trackOrigin,
pitch: pitch,
hasEarlierDots: isRtl
? hasLaterDots
: hasEarlierDots,
hasLaterDots: isRtl
? hasEarlierDots
: 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('$dotKeyPrefix$page'),
duration: duration,
curve: Curves.easeInOutCubic,
left: centerX - diameter / 2,
top: (containerHeight - diameter) / 2,
width: diameter,
height: diameter,
child: AnimatedOpacity(
duration: duration,
opacity: isVisible ? 1 : 0,
child: DecoratedBox(
decoration: BoxDecoration(
color: page == selected
? context.colors.onSurface
: context.colors.onSurfaceVariant.withValues(alpha: 0.32),
shape: BoxShape.circle,
),
),
),
);
}
}

View File

@@ -0,0 +1,800 @@
import 'dart:async';
import 'dart:io';
import 'package:buzz/features/channels/media_viewer_page.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:video_player_platform_interface/video_player_platform_interface.dart';
import 'package:video_player/video_player.dart';
class _TempDirectory extends Fake
with MockPlatformInterfaceMixin
implements PathProviderPlatform {
@override
Future<String?> getTemporaryPath() async => Directory.systemTemp.path;
}
class _Player extends VideoPlayerPlatform {
_Player({this.size = const Size(1920, 1080)});
final Size size;
final started = Completer<void>();
final events = StreamController<VideoEvent>();
Duration position = Duration.zero;
double volume = 1;
double speed = 1;
int seeks = 0;
bool failNextSeek = false;
bool deferSeeks = false;
final pendingSeeks = <Completer<void>>[];
int plays = 0;
int pauses = 0;
@override
Future<void> init() async {}
@override
Future<int?> createWithOptions(VideoCreationOptions options) async => 1;
@override
Stream<VideoEvent> videoEventsFor(int playerId) {
events.onListen = () => events.add(
VideoEvent(
eventType: VideoEventType.initialized,
duration: const Duration(minutes: 2),
size: size,
),
);
return events.stream;
}
@override
Widget buildView(int playerId) => const ColoredBox(color: Colors.blueGrey);
@override
Future<void> play(int playerId) async {
plays++;
if (!started.isCompleted) started.complete();
}
@override
Future<void> pause(int playerId) async {
pauses++;
}
@override
Future<void> setLooping(int playerId, bool looping) async {}
@override
Future<void> setVolume(int playerId, double volume) async {
this.volume = volume;
}
@override
Future<void> setPlaybackSpeed(int playerId, double speed) async {
this.speed = speed;
}
@override
Future<void> setMixWithOthers(bool mixWithOthers) async {}
@override
Future<Duration> getPosition(int playerId) async => position;
@override
Future<void> seekTo(int playerId, Duration position) async {
seeks++;
if (failNextSeek) {
failNextSeek = false;
throw PlatformException(code: 'seek_failed');
}
if (deferSeeks) {
final pending = Completer<void>();
pendingSeeks.add(pending);
await pending.future;
}
this.position = position;
}
@override
Future<void> dispose(int playerId) async {
await events.close();
}
}
final _surface = find.byKey(
const ValueKey('message-media-video-viewer-gesture'),
);
final _play = find.byKey(
const ValueKey('message-media-video-viewer-play-pause'),
);
final _timeline = find.byKey(
const ValueKey('message-media-video-viewer-timeline'),
);
final _chrome = find.byKey(
const ValueKey('message-media-video-viewer-controls'),
);
double _opacity(WidgetTester tester) => tester
.widget<AnimatedOpacity>(
find.descendant(of: _chrome, matching: find.byType(AnimatedOpacity)),
)
.opacity;
Future<_Player> _pumpVideo(
WidgetTester tester, {
bool reduceMotion = false,
bool accessible = false,
bool pushRoute = false,
double textScale = 1,
VoidCallback? onReply,
Size videoSize = const Size(1920, 1080),
}) async {
final navigatorKey = GlobalKey<NavigatorState>();
final oldPlayer = VideoPlayerPlatform.instance;
final oldPath = PathProviderPlatform.instance;
final player = _Player(size: videoSize);
VideoPlayerPlatform.instance = player;
PathProviderPlatform.instance = _TempDirectory();
addTearDown(() {
VideoPlayerPlatform.instance = oldPlayer;
PathProviderPlatform.instance = oldPath;
});
addTearDown(() => _disposeVideo(tester));
final client = MockClient(
(_) async => http.Response.bytes([0, 1, 2, 3], 200),
);
addTearDown(client.close);
await tester.runAsync(() async {
await tester.pumpWidget(
ProviderScope(
overrides: [
mediaHttpClientProvider.overrideWithValue(client),
mediaGetAuthServiceProvider.overrideWithValue(
MediaGetAuthService(baseUrl: 'https://relay.test', nsec: null),
),
],
child: MaterialApp(
navigatorKey: navigatorKey,
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(
disableAnimations: reduceMotion,
accessibleNavigation: accessible,
textScaler: TextScaler.linear(textScale),
),
child: child!,
),
home: pushRoute
? const Scaffold(body: Text('Viewer closed'))
: MediaVideoViewerPage(
videoUrl: 'https://relay.test/video.mp4',
onReply: onReply,
),
),
),
);
if (pushRoute) {
unawaited(
navigatorKey.currentState!.push(
MaterialPageRoute<void>(
builder: (_) => MediaVideoViewerPage(
videoUrl: 'https://relay.test/video.mp4',
onReply: onReply,
),
),
),
);
await tester.pump();
}
await Future<void>.delayed(const Duration(milliseconds: 50));
});
for (
var attempt = 0;
attempt < 100 && !player.started.isCompleted;
attempt++
) {
await tester.pump();
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 10)),
);
}
expect(player.started.isCompleted, isTrue, reason: 'Video initialized');
await tester.pump();
await tester.pump();
return player;
}
Future<void> _disposeVideo(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox.shrink());
await tester.runAsync(() async {
await Future<void>.delayed(const Duration(milliseconds: 10));
});
}
void main() {
testWidgets(
'playing controls fade after idle and video taps hide or restore them',
(tester) async {
final player = await _pumpVideo(tester);
final initialPauses = player.pauses;
expect(_opacity(tester), 1);
await tester.pump(const Duration(milliseconds: 2900));
expect(_opacity(tester), 1);
await tester.pump(const Duration(milliseconds: 101));
expect(_opacity(tester), 0);
await tester.pump(const Duration(milliseconds: 200));
expect(_play.hitTestable(), findsNothing);
expect(find.bySemanticsLabel('Pause video'), findsNothing);
await tester.tap(_surface);
await tester.pumpAndSettle();
expect(_opacity(tester), 1);
expect(_play.hitTestable(), findsOneWidget);
await tester.tap(_surface);
await tester.pumpAndSettle();
expect(_opacity(tester), 0);
expect(
player.pauses,
initialPauses,
reason: 'Tapping the picture only toggles chrome',
);
await _disposeVideo(tester);
},
);
testWidgets(
'pausing keeps controls visible and resume starts a fresh timer',
(tester) async {
final player = await _pumpVideo(tester);
final initialPauses = player.pauses;
await tester.tap(_play);
await tester.pump();
await tester.pump(const Duration(seconds: 5));
expect(_opacity(tester), 1);
expect(player.pauses, initialPauses + 1);
await tester.tap(_surface);
await tester.pumpAndSettle();
expect(
_opacity(tester),
0,
reason: 'Paused controls can still be hidden manually',
);
await tester.tap(_surface);
await tester.pumpAndSettle();
await tester.tap(_play);
await tester.pump();
await tester.pump(const Duration(seconds: 2));
expect(_opacity(tester), 1);
await tester.pump(const Duration(seconds: 1));
expect(_opacity(tester), 0);
await _disposeVideo(tester);
},
);
testWidgets(
'scrubbing holds controls open, seeks on release, then restarts idle',
(tester) async {
final player = await _pumpVideo(tester);
await tester.pump(const Duration(seconds: 2));
final rect = tester.getRect(_timeline);
final gesture = await tester.startGesture(rect.center);
await gesture.moveBy(Offset(rect.width * 0.2, 0));
await tester.pump();
await tester.pump(const Duration(seconds: 4));
expect(_opacity(tester), 1);
expect(
player.seeks,
0,
reason: 'Do not flood native decoder with pending seeks',
);
await gesture.up();
await tester.pump();
expect(player.position.inSeconds, greaterThan(60));
expect(player.seeks, 1);
await tester.pump(const Duration(seconds: 2));
expect(_opacity(tester), 1);
await tester.pump(const Duration(seconds: 1));
expect(_opacity(tester), 0);
await _disposeVideo(tester);
},
);
for (final oldSeekFails in [false, true]) {
for (final releaseNewScrub in [false, true]) {
testWidgets(
'stale seek cannot end newer scrub: failure=$oldSeekFails, released=$releaseNewScrub',
(tester) async {
final player = await _pumpVideo(tester);
player.deferSeeks = true;
final rect = tester.getRect(_timeline);
await tester.tapAt(
Offset(rect.left + rect.width * 0.25, rect.center.dy),
);
await tester.pump();
expect(player.pendingSeeks, hasLength(1));
final newer = await tester.startGesture(rect.center);
await newer.moveTo(
Offset(rect.left + rect.width * 0.8, rect.center.dy),
);
await tester.pump();
final newPosition = tester.widget<Slider>(_timeline).value;
expect(newPosition, greaterThan(60000));
if (releaseNewScrub) {
await newer.up();
await tester.pump();
expect(player.pendingSeeks, hasLength(2));
}
if (oldSeekFails) {
player.pendingSeeks.first.completeError(
PlatformException(code: 'old_seek_failed'),
);
} else {
player.pendingSeeks.first.complete();
}
await tester.pump();
expect(
tester.widget<Slider>(_timeline).value,
newPosition,
reason: 'Old completion must not clear the newer thumb',
);
expect(find.text('Could not seek in this video.'), findsNothing);
expect(find.text('Retry'), findsNothing);
await tester.pump(const Duration(seconds: 4));
expect(
_opacity(tester),
1,
reason: 'New scrub still owns the idle hold',
);
if (!releaseNewScrub) {
await newer.up();
await tester.pump();
}
expect(player.pendingSeeks, hasLength(2));
player.pendingSeeks.last.complete();
await tester.pump();
await tester.pump();
expect(player.position.inMilliseconds, newPosition.round());
await tester.pump(const Duration(seconds: 3));
expect(
_opacity(tester),
0,
reason: 'Only current completion resumes idle',
);
},
);
}
}
testWidgets('failed seeks report an error and retry the requested position', (
tester,
) async {
final player = await _pumpVideo(tester);
player.failNextSeek = true;
final rect = tester.getRect(_timeline);
await tester.tapAt(Offset(rect.left + rect.width * 0.75, rect.center.dy));
await tester.pumpAndSettle();
expect(player.seeks, 1);
expect(player.position, Duration.zero);
expect(find.text('Could not seek in this video.'), findsOneWidget);
// Feedback stays actionable even after the transport chrome fades.
await tester.pump(const Duration(seconds: 3));
expect(_opacity(tester), 0);
await tester.tap(find.text('Retry'));
await tester.pumpAndSettle();
expect(player.seeks, 2);
expect(player.position.inSeconds, greaterThan(80));
expect(find.text('Could not seek in this video.'), findsNothing);
expect(_opacity(tester), 1);
await _disposeVideo(tester);
});
testWidgets('mute and playback speed work without hiding controls', (
tester,
) async {
final player = await _pumpVideo(tester);
await tester.pump(const Duration(seconds: 2));
await tester.tap(
find.byKey(const ValueKey('message-media-video-viewer-mute')),
);
await tester.pump();
expect(player.volume, 0);
await tester.pump(const Duration(seconds: 2));
expect(_opacity(tester), 1);
for (final expected in [1.25, 1.5, 1.75, 2.0, 0.5, 0.75, 1.0]) {
await tester.tap(
find.byKey(const ValueKey('message-media-video-viewer-speed')),
);
await tester.pump();
expect(player.speed, expected);
expect(find.byType(CheckedPopupMenuItem<double>), findsNothing);
await tester.pump(const Duration(seconds: 2));
expect(_opacity(tester), 1);
}
await tester.pump(const Duration(seconds: 3));
expect(_opacity(tester), 0);
await _disposeVideo(tester);
});
testWidgets(
'accessible navigation keeps controls available and reduce motion is instant',
(tester) async {
final semantics = tester.ensureSemantics();
await _pumpVideo(tester, accessible: true, reduceMotion: true);
await tester.pump(const Duration(seconds: 10));
expect(_opacity(tester), 1);
await tester.tap(_surface);
await tester.pump();
expect(_opacity(tester), 0);
expect(find.bySemanticsLabel('Pause video'), findsNothing);
final restore = find.bySemanticsLabel('Show video controls');
expect(restore, findsOneWidget);
final restoreNode = tester.getSemantics(restore);
expect(
restoreNode.getSemanticsData().hasAction(SemanticsAction.tap),
isTrue,
);
final semanticsOwner = tester
.renderObject(_surface)
.owner!
.semanticsOwner!;
final tapStops = <SemanticsData>[];
void countTapStops(SemanticsNode node) {
if (node.getSemanticsData().hasAction(SemanticsAction.tap)) {
tapStops.add(node.getSemanticsData());
}
node.visitChildren((child) {
countTapStops(child);
return true;
});
}
countTapStops(semanticsOwner.rootSemanticsNode!);
expect(
tapStops,
hasLength(1),
reason: 'One named restore stop, no anonymous duplicate',
);
final fade = tester.widget<AnimatedOpacity>(
find.descendant(of: _chrome, matching: find.byType(AnimatedOpacity)),
);
expect(fade.duration, Duration.zero);
semanticsOwner.performAction(restoreNode.id, SemanticsAction.tap);
await tester.pump();
expect(_opacity(tester), 1);
expect(find.bySemanticsLabel('Hide video controls'), findsOneWidget);
expect(find.bySemanticsLabel('Show video controls'), findsNothing);
tapStops.clear();
countTapStops(semanticsOwner.rootSemanticsNode!);
expect(
tapStops.map(
(node) => node.label.isNotEmpty ? node.label : node.tooltip,
),
containsAll([
'Hide video controls',
'Close video viewer',
'Pause video',
]),
);
semantics.dispose();
await _disposeVideo(tester);
},
);
testWidgets('buffering and playback completion restore controls', (
tester,
) async {
final player = await _pumpVideo(tester);
await tester.pump(const Duration(seconds: 3));
expect(_opacity(tester), 0);
player.events.add(VideoEvent(eventType: VideoEventType.bufferingStart));
await tester.pump();
await tester.pump(const Duration(seconds: 4));
expect(_opacity(tester), 1);
player.events.add(VideoEvent(eventType: VideoEventType.bufferingEnd));
await tester.pump();
await tester.pump();
await tester.pump(const Duration(seconds: 3));
expect(_opacity(tester), 0);
player.events.add(VideoEvent(eventType: VideoEventType.completed));
await tester.pumpAndSettle();
expect(_opacity(tester), 1);
expect(find.byTooltip('Play video'), findsOneWidget);
await _disposeVideo(tester);
});
testWidgets(
'iOS timeline requests native Liquid Glass and fades with the toolbar',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
final nativeChannels = <MethodChannel>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform_views,
(call) async {
if (call.method == 'create') {
final args = call.arguments as Map;
final native = MethodChannel('${args['viewType']}/${args['id']}');
nativeChannels.add(native);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
native,
(_) async => null,
);
}
return null;
},
);
addTearDown(() {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform_views,
null,
);
for (final channel in nativeChannels) {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
);
}
});
const channel = MethodChannel('buzz/concentric_sheet_surface');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
(call) async => true,
);
addTearDown(() {
debugDefaultTargetPlatformOverride = null;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
);
});
await _pumpVideo(tester);
await tester.pumpAndSettle();
final native = tester.widgetList<UiKitView>(
find.byWidgetPredicate(
(widget) =>
widget is UiKitView &&
widget.viewType == 'buzz/concentric_sheet_surface',
),
);
expect(native, hasLength(3));
for (final surface in native) {
expect(
(surface.creationParams! as Map<String, Object>)['usesGlass'],
isTrue,
);
}
await tester.tap(_surface);
await tester.pumpAndSettle();
expect(_opacity(tester), 0);
expect(_play.hitTestable(), findsNothing);
await _disposeVideo(tester);
debugDefaultTargetPlatformOverride = null;
},
);
for (final width in [320.0, 420.0]) {
testWidgets('transport and reply actions use two rows at $width pixels', (
tester,
) async {
tester.view.physicalSize = Size(width, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpVideo(tester, onReply: () {});
final centerY = tester.getCenter(_play).dy;
for (final control in [
_timeline,
find.byKey(const ValueKey('message-media-video-viewer-current-time')),
find.byKey(const ValueKey('message-media-video-viewer-total-time')),
]) {
expect(tester.getCenter(control).dy, closeTo(centerY, 0.01));
expect(tester.getRect(control).left, greaterThanOrEqualTo(0));
expect(tester.getRect(control).right, lessThanOrEqualTo(width));
}
final reply = find.byKey(
const ValueKey('message-media-video-viewer-reply-thread'),
);
final lowerY = tester.getCenter(reply).dy;
expect(lowerY, greaterThan(centerY));
for (final control in [
find.byKey(const ValueKey('message-media-video-viewer-speed')),
find.byKey(const ValueKey('message-media-video-viewer-mute')),
]) {
expect(tester.getCenter(control).dy, closeTo(lowerY, 0.01));
expect(tester.getRect(control).right, lessThanOrEqualTo(width));
}
expect(tester.getSize(_timeline).width, greaterThanOrEqualTo(64));
expect(tester.takeException(), isNull);
await _disposeVideo(tester);
});
}
testWidgets(
'pinch zoom and pan preserve playback and controls, then reset to fit',
(tester) async {
final player = await _pumpVideo(tester);
final viewer = tester.widget<InteractiveViewer>(
find.byKey(const ValueKey('message-media-video-viewer-zoom')),
);
final transform = viewer.transformationController!;
final center = tester.getCenter(_surface);
final pausesBefore = player.pauses;
Future<void> pinch(double start, double end) async {
final left = await tester.startGesture(
center - Offset(start, 0),
pointer: 1,
);
final right = await tester.startGesture(
center + Offset(start, 0),
pointer: 2,
);
for (var step = 1; step <= 8; step++) {
final radius = start + (end - start) * step / 8;
await left.moveTo(center - Offset(radius, 0));
await right.moveTo(center + Offset(radius, 0));
await tester.pump(const Duration(milliseconds: 16));
}
await left.up();
await right.up();
await tester.pumpAndSettle();
}
await pinch(30, 240);
expect(transform.value.getMaxScaleOnAxis(), greaterThan(1));
expect(transform.value.getMaxScaleOnAxis(), lessThanOrEqualTo(4));
final beforePan = Matrix4.copy(transform.value);
final pan = await tester.startGesture(center);
await pan.moveBy(const Offset(0, 150));
await tester.pump();
await pan.moveBy(const Offset(0, 40));
await tester.pump();
await tester.pump(const Duration(seconds: 4));
expect(_opacity(tester), 1);
await pan.up();
await tester.pumpAndSettle();
expect(transform.value.storage, isNot(orderedEquals(beforePan.storage)));
expect(player.pauses, pausesBefore);
expect(
tester.widget<Scaffold>(find.byType(Scaffold)).backgroundColor!.a,
1,
);
expect(_play.hitTestable(), findsOneWidget);
await tester.tap(_play);
await tester.pump();
expect(player.pauses, pausesBefore + 1);
await pinch(240, 10);
expect(
transform.value.storage,
orderedEquals(Matrix4.identity().storage),
);
expect(tester.takeException(), isNull);
await _disposeVideo(tester);
},
);
for (final reduceMotion in [false, true]) {
testWidgets(
'double tap recenters video and enables swipe to close (reduced motion: $reduceMotion)',
(tester) async {
final player = await _pumpVideo(
tester,
pushRoute: true,
reduceMotion: reduceMotion,
);
await tester.pumpAndSettle();
final viewer = tester.widget<InteractiveViewer>(
find.byKey(const ValueKey('message-media-video-viewer-zoom')),
);
final transform = viewer.transformationController!;
transform.value = Matrix4.identity()
..translateByDouble(-120, -100, 0, 1)
..scaleByDouble(2, 2, 1, 1);
await tester.pump();
final initialPauses = player.pauses;
final initialPlays = player.plays;
// Reset while a zoomed pan still has momentum.
await tester.fling(_surface, const Offset(0, 120), 1500);
await tester.pump(const Duration(milliseconds: 16));
await tester.tap(_surface);
await tester.pump(const Duration(milliseconds: 50));
await tester.tap(_surface);
await tester.pumpAndSettle();
expect(
transform.value.storage,
orderedEquals(Matrix4.identity().storage),
);
await tester.pump(const Duration(milliseconds: 500));
expect(
transform.value.storage,
orderedEquals(Matrix4.identity().storage),
);
expect(player.pauses, initialPauses);
expect(player.plays, initialPlays);
expect(
_opacity(tester),
1,
reason: 'Double tap does not toggle the controls',
);
expect(
tester
.widget<InteractiveViewer>(find.byType(InteractiveViewer))
.panEnabled,
isFalse,
);
await tester.drag(_surface, const Offset(0, 160));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('message-media-video-viewer')),
findsNothing,
);
expect(find.text('Viewer closed'), findsOneWidget);
await _disposeVideo(tester);
},
);
}
testWidgets('a second finger cancels a video dismiss drag', (tester) async {
final player = await _pumpVideo(tester, reduceMotion: true);
final center = tester.getCenter(_surface);
final pausesBefore = player.pauses;
final first = await tester.startGesture(
center - const Offset(40, 0),
pointer: 1,
);
await first.moveBy(const Offset(0, 50));
await tester.pump();
final second = await tester.startGesture(
center + const Offset(40, 50),
pointer: 2,
);
await first.moveBy(const Offset(-70, 120));
await second.moveBy(const Offset(70, 120));
await tester.pump();
await first.up();
await second.up();
await tester.pumpAndSettle();
expect(player.pauses, pausesBefore);
expect(
tester.widget<Scaffold>(find.byType(Scaffold)).backgroundColor!.a,
1,
);
expect(
find.byKey(const ValueKey('message-media-video-viewer')),
findsOneWidget,
);
await _disposeVideo(tester);
});
testWidgets('portrait video uses the same bounded stage as photos', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
tester.view.viewPadding = const FakeViewPadding(top: 59, bottom: 34);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetViewPadding);
await _pumpVideo(tester, videoSize: const Size(900, 3000));
final bounds = tester.getRect(find.byType(VideoPlayer));
expect(bounds.top, closeTo(59 + 48 + 8, 0.01));
expect(bounds.bottom, closeTo(844 - 34 - 56 - 16, 0.01));
expect(bounds.width / bounds.height, closeTo(900 / 3000, 0.001));
await tester.tap(_surface);
await tester.pumpAndSettle();
expect(tester.getRect(find.byType(VideoPlayer)), bounds);
await _disposeVideo(tester);
});
testWidgets('timeline fits a narrow phone with large text', (tester) async {
tester.view.physicalSize = const Size(320, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpVideo(tester, textScale: 2);
expect(_timeline, findsOneWidget);
expect(_play.hitTestable(), findsOneWidget);
await _disposeVideo(tester);
});
}

View File

@@ -1,3 +1,5 @@
import 'package:buzz/shared/widgets/sheet_action_section.dart';
import 'package:buzz/shared/widgets/app_list_card.dart';
import 'dart:async'; import 'dart:async';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -1883,9 +1885,32 @@ void main() {
expect(find.text('Delete message'), findsOneWidget); expect(find.text('Delete message'), findsOneWidget);
expect(find.text('Delete upload'), findsNothing); expect(find.text('Delete upload'), findsNothing);
expect(find.byType(AppListCard), findsNWidgets(2));
expect(
find.descendant(
of: find.byType(SheetActionSection),
matching: find.byType(ListTile),
),
findsNWidgets(4),
);
}); });
}); });
testWidgets('image sheet omits message deletion without permission', (
tester,
) async {
await _pumpImageSheet(tester, message: _message(), canManageMessage: false);
expect(find.byType(AppListCard), findsOneWidget);
expect(
find.descendant(
of: find.byType(SheetActionSection),
matching: find.byType(ListTile),
),
findsNWidgets(3),
);
expect(find.text('Delete message'), findsNothing);
});
group('downloadedImageFilename', () { group('downloadedImageFilename', () {
test('preserves gif file extensions', () { test('preserves gif file extensions', () {
expect( expect(

View File

@@ -1,6 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/rendering.dart' show RenderRepaintBoundary;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:buzz/shared/widgets/page_indicator.dart';
import 'package:flutter/semantics.dart'; import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -1673,29 +1678,16 @@ Photos
findsOneWidget, findsOneWidget,
); );
expect( expect(
find.byKey(const ValueKey('message-media-image-viewer-filmstrip')), find.byKey(const ValueKey('message-media-image-viewer-pagination')),
findsOneWidget,
);
expect(
find.byKey(
const ValueKey('message-media-image-viewer-thumbnail:1'),
),
findsOneWidget, findsOneWidget,
); );
final displayedImage = tester.widget<MediaImage>( final displayedImage = tester.widget<MediaImage>(
find.byKey(const ValueKey('message-media-image-viewer-image:1')), find.byKey(const ValueKey('message-media-image-viewer-image:1')),
); );
expect(displayedImage.decodeWidth, isNotNull); expect(displayedImage.decodeWidth, isNotNull);
final selectedThumbnailClip = tester.widget<ClipRRect>(
find.byKey(
const ValueKey('message-media-image-viewer-thumbnail-clip:1'),
),
);
final selectedThumbnailRadius =
selectedThumbnailClip.borderRadius as BorderRadius;
expect( expect(
selectedThumbnailRadius.topLeft.x, tester.widget<PageIndicator>(find.byType(PageIndicator)).selected,
closeTo(Radii.sm - 2.5, 0.01), 1,
); );
await tester.fling( await tester.fling(
@@ -1705,111 +1697,203 @@ Photos
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final thirdThumbnail = find.byKey( expect(
const ValueKey('message-media-image-viewer-thumbnail:2'), tester.widget<PageIndicator>(find.byType(PageIndicator)).selected,
2,
); );
final thirdSemantics = tester.widget<Semantics>( expect(find.bySemanticsLabel('Image 3 of 3'), findsOneWidget);
find
.ancestor(of: thirdThumbnail, matching: find.byType(Semantics))
.first,
);
expect(thirdSemantics.properties.selected, isTrue);
}, },
); );
testWidgets( testWidgets(
'keeps adjacent carousel images active and ends with a gutter', 'paints neighboring photos through the avatar gutter after paging',
(tester) async { (tester) async {
const first = 'https://example.com/media/gutter-one.png'; const connectivity = MethodChannel(
const second = 'https://example.com/media/gutter-two.png'; 'dev.fluttercommunity.plus/connectivity_status',
);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
connectivity,
(_) async => null,
);
addTearDown(
() => tester.binding.defaultBinaryMessenger
.setMockMethodCallHandler(connectivity, null),
);
_setSurfaceSize(tester, const Size(390, 844));
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const urls = [
'https://example.com/media/gutter-one.png',
'https://example.com/media/gutter-two.png',
'https://example.com/media/gutter-three.png',
];
final boundaryKey = GlobalKey();
await tester.pumpWidget( await tester.pumpWidget(
_testable( _testable(
const MessageContent( RepaintBoundary(
content: key: boundaryKey,
''' child: Padding(
![image]($first) padding: const EdgeInsets.only(left: 64, right: 16),
![image]($second) child: MessageContent(
''', mediaCarouselTrailingOverflow: 16,
tags: [ content: urls.map((url) => '![image]($url)').join('\n'),
['imeta', 'url $first', 'm image/png'], tags: [
['imeta', 'url $second', 'm image/png'], for (final url in urls)
], ['imeta', 'url $url', 'm image/png'],
],
),
),
), ),
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final carousel = find.byKey(const ValueKey('message-media-carousel')); final carousel = find.byKey(const ValueKey('message-media-carousel'));
final pageViewFinder = find.descendant( final scroll = tester.widget<CustomScrollView>(
of: carousel, find.descendant(
matching: find.byType(PageView), of: carousel,
matching: find.byType(CustomScrollView),
),
); );
final pageView = tester.widget<PageView>(pageViewFinder); final controller = scroll.controller! as PageController;
final first = find.byKey(
ValueKey('message-media-carousel-item:${urls[0]}'),
);
expect(tester.getRect(first).left, 64);
final sampleY = tester.getRect(first).top.toInt() + 30;
expect(pageView.allowImplicitScrolling, isTrue); Future<List<int>> pixel(int x) async {
expect(pageView.clipBehavior, Clip.none); return (await tester.runAsync(() async {
final boundary =
boundaryKey.currentContext!.findRenderObject()!
as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 1);
final bytes = (await image.toByteData(
format: ui.ImageByteFormat.rawRgba,
))!;
final offset = (sampleY * image.width + x) * 4;
final rgba = bytes.buffer.asUint8List().sublist(
offset,
offset + 4,
);
image.dispose();
return rgba;
}))!;
}
pageView.controller!.jumpToPage(1); final photoColor = await pixel(100);
expect(await pixel(20), isNot(photoColor));
controller.jumpToPage(1);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// A layout-only assertion missed the original bug: the old sliver
// retained the image but stopped painting it in this visible gutter.
expect(tester.getRect(first).right, closeTo(64 - Grid.half, 0.01));
expect(await pixel(20), photoColor);
await tester.pump(const Duration(seconds: 2));
expect(await pixel(20), photoColor);
final lastCard = find.byKey( controller.jumpToPage(2);
const ValueKey('message-media-carousel-item:$second'), await tester.pumpAndSettle();
final last = find.byKey(
ValueKey('message-media-carousel-item:${urls[2]}'),
); );
expect( expect(
tester.getRect(carousel).right - tester.getRect(lastCard).right, tester.getRect(carousel).right - tester.getRect(last).right,
Grid.gutter, closeTo(Grid.gutter, 0.01),
); );
expect(await pixel(20), photoColor);
controller.jumpToPage(0);
await tester.pumpAndSettle();
expect(tester.getRect(first).left, 64);
expect(await pixel(20), isNot(photoColor));
}, },
); );
testWidgets( testWidgets('jumps to a selected gallery dot when motion is disabled', (
'jumps to a selected gallery thumbnail when motion is disabled', tester,
(tester) async { ) async {
const first = 'https://example.com/media/reduced-motion-one.png'; const first = 'https://example.com/media/reduced-motion-one.png';
const second = 'https://example.com/media/reduced-motion-two.png'; const second = 'https://example.com/media/reduced-motion-two.png';
await tester.pumpWidget( await tester.pumpWidget(
_testable( _testable(
const MessageContent( const MessageContent(
content: content:
''' '''
![image]($first) ![image]($first)
![image]($second) ![image]($second)
''', ''',
tags: [ tags: [
['imeta', 'url $first', 'm image/png'], ['imeta', 'url $first', 'm image/png'],
['imeta', 'url $second', 'm image/png'], ['imeta', 'url $second', 'm image/png'],
], ],
),
disableAnimations: true,
), ),
); disableAnimations: true,
await tester.pumpAndSettle(); ),
);
await tester.pumpAndSettle();
await tester.tap( await tester.tap(
find.byKey(const ValueKey('message-media-carousel-item:$first')), find.byKey(const ValueKey('message-media-carousel-item:$first')),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap( final indicatorFinder = find.byType(PageIndicator);
find.byKey( await tester.tap(find.byKey(const ValueKey('page-indicator-dot-1')));
const ValueKey('message-media-image-viewer-thumbnail:1'), await tester.pump();
expect(tester.widget<PageIndicator>(indicatorFinder).selected, 1);
final pages = tester.widget<PageView>(
find.byKey(const ValueKey('message-media-image-viewer-pages')),
);
expect(pages.controller!.page, 1);
expect(tester.takeException(), isNull);
});
testWidgets('uses glass photo controls and image pagination on iOS', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await tester.pumpWidget(
_testable(
MediaImageViewerPage(
imageUrl: 'https://example.com/one.png',
heroTag: 'one',
galleryItems: const [
MediaViewerImage(
url: 'https://example.com/one.png',
heroTag: 'one',
),
MediaViewerImage(
url: 'https://example.com/two.png',
heroTag: 'two',
),
],
onReply: () {},
onMore: (_, _) {},
), ),
); ),
await tester.pumpAndSettle(); );
await tester.pumpAndSettle();
final selectedThumbnail = tester.widget<Semantics>( final views = tester.widgetList<UiKitView>(find.byType(UiKitView));
find final pagination = views.singleWhere(
.ancestor( (view) => view.viewType == 'buzz/theme_pagination_glass',
of: find.byKey( );
const ValueKey('message-media-image-viewer-thumbnail:1'), final params = pagination.creationParams! as Map<String, Object>;
), expect(params['accessibilityLabel'], 'Image');
matching: find.byType(Semantics), expect(params['containerHeight'], 48);
) expect(params['brightness'], 'dark');
.first, expect(params['count'], 2);
); expect(params['selected'], 0);
expect(selectedThumbnail.properties.selected, isTrue); final buttons = views.where(
expect(tester.takeException(), isNull); (view) => view.viewType == 'buzz/navigation_glass',
}, );
); expect(
buttons.map(
(view) => (view.creationParams! as Map<String, Object>)['icon'],
),
unorderedEquals(['reply', 'more']),
);
expect(tester.takeException(), isNull);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('resets carousel paging when gallery images change', ( testWidgets('resets carousel paging when gallery images change', (
tester, tester,
@@ -1835,10 +1919,10 @@ Photos
await tester.pumpWidget(gallery(firstGallery)); await tester.pumpWidget(gallery(firstGallery));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final firstCarousel = tester.widget<PageView>( final firstCarousel = tester.widget<CustomScrollView>(
find.descendant( find.descendant(
of: find.byKey(const ValueKey('message-media-carousel')), of: find.byKey(const ValueKey('message-media-carousel')),
matching: find.byType(PageView), matching: find.byType(CustomScrollView),
), ),
); );
@@ -1848,14 +1932,17 @@ Photos
1200, 1200,
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(firstCarousel.controller!.page, greaterThan(0)); expect(
(firstCarousel.controller! as PageController).page,
greaterThan(0),
);
await tester.pumpWidget(gallery(secondGallery)); await tester.pumpWidget(gallery(secondGallery));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final secondCarousel = tester.widget<PageView>( final secondCarousel = tester.widget<CustomScrollView>(
find.descendant( find.descendant(
of: find.byKey(const ValueKey('message-media-carousel')), of: find.byKey(const ValueKey('message-media-carousel')),
matching: find.byType(PageView), matching: find.byType(CustomScrollView),
), ),
); );
@@ -1863,7 +1950,7 @@ Photos
secondCarousel.controller, secondCarousel.controller,
isNot(same(firstCarousel.controller)), isNot(same(firstCarousel.controller)),
); );
expect(secondCarousel.controller!.page, 0); expect((secondCarousel.controller! as PageController).page, 0);
}); });
testWidgets( testWidgets(

View File

@@ -666,12 +666,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(selectionHaptics, 1); expect(selectionHaptics, 1);
final scrubber = tester.getRect( await tester.tap(find.byKey(const ValueKey('theme-pagination-dot-1')));
find.byKey(const ValueKey('theme-preview-scrubber')),
);
await tester.tapAt(
Offset(scrubber.left + scrubber.width * 0.75, scrubber.center.dy),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(selectionHaptics, 2); expect(selectionHaptics, 2);
}); });

View File

@@ -0,0 +1,279 @@
import 'package:buzz/shared/widgets/page_indicator.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets(
'iOS receives initial and updated layout direction',
(tester) async {
final updates = <Map<Object?, Object?>>[];
const channel = MethodChannel('buzz/theme_pagination_glass/54321');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
call,
) async {
if (call.method == 'setState') {
updates.add(call.arguments as Map<Object?, Object?>);
}
return null;
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
Future<void> pump(TextDirection direction) => tester.pumpWidget(
WidgetHelpers.testable(
child: Directionality(
textDirection: direction,
child: PageIndicator(
semanticLabel: 'Photo',
count: 20,
selected: 10,
animateChanges: false,
onSelected: (_) {},
),
),
),
);
await pump(TextDirection.rtl);
final native = tester.widget<UiKitView>(find.byType(UiKitView));
expect((native.creationParams as Map)['isRTL'], isTrue);
native.onPlatformViewCreated!(54321);
await tester.pump();
expect(updates.last['isRTL'], isTrue);
await pump(TextDirection.ltr);
expect(updates.last['isRTL'], isFalse);
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
testWidgets(
'pagination has a single platform semantics owner',
(tester) async {
final semantics = tester.ensureSemantics();
try {
final selected = ValueNotifier(1);
addTearDown(selected.dispose);
await tester.pumpWidget(
WidgetHelpers.testable(
child: ValueListenableBuilder<int>(
valueListenable: selected,
builder: (context, page, _) => PageIndicator(
semanticLabel: 'Photo',
count: 3,
selected: page,
animateChanges: false,
onSelected: (page) => selected.value = page,
),
),
),
);
List<SemanticsNode> adjustableNodes() {
final nodes = <SemanticsNode>[];
void visit(SemanticsNode node) {
final data = node.getSemanticsData();
if (data.hasAction(SemanticsAction.increase) ||
data.hasAction(SemanticsAction.decrease)) {
nodes.add(node);
}
node.visitChildren((child) {
visit(child);
return true;
});
}
visit(
tester
.renderObject(find.byType(PageIndicator))
.owner!
.semanticsOwner!
.rootSemanticsNode!,
);
return nodes;
}
if (defaultTargetPlatform == TargetPlatform.iOS) {
// Flutter must preserve the native view bridge without adding a second
// adjustable owner. The UIKit regression checks that view's actions.
expect(adjustableNodes(), isEmpty);
final native = tester.widget<UiKitView>(find.byType(UiKitView));
expect((native.creationParams as Map)['accessibilityLabel'], 'Photo');
expect((native.creationParams as Map)['selected'], 1);
expect((native.creationParams as Map)['count'], 3);
expect(
find.ancestor(
of: find.byType(UiKitView),
matching: find.byType(ExcludeSemantics),
),
findsNothing,
);
} else {
expect(adjustableNodes(), hasLength(1));
var node = adjustableNodes().single;
expect(node.label, 'Photo 2 of 3');
expect(node.value, '2');
tester
.renderObject(find.byType(PageIndicator))
.owner!
.semanticsOwner!
.performAction(node.id, SemanticsAction.increase);
await tester.pump();
expect(selected.value, 2);
node = adjustableNodes().single;
expect(node.label, 'Photo 3 of 3');
expect(node.value, '3');
expect(
node.getSemanticsData().hasAction(SemanticsAction.increase),
isFalse,
);
tester
.renderObject(find.byType(PageIndicator))
.owner!
.semanticsOwner!
.performAction(node.id, SemanticsAction.decrease);
await tester.pump();
expect(selected.value, 1);
expect(adjustableNodes(), hasLength(1));
}
} finally {
semantics.dispose();
}
},
variant: TargetPlatformVariant({
TargetPlatform.iOS,
TargetPlatform.android,
}),
);
for (final direction in TextDirection.values) {
for (final cancel in [false, true]) {
testWidgets(
'scrub window stays fixed across rebuilds in $direction, cancel=$cancel',
(tester) async {
final selected = ValueNotifier(10);
addTearDown(selected.dispose);
await tester.pumpWidget(
WidgetHelpers.testable(
child: Directionality(
textDirection: direction,
child: Center(
child: SizedBox(
width: 390,
child: ValueListenableBuilder<int>(
valueListenable: selected,
builder: (context, page, _) => PageIndicator(
semanticLabel: 'Photo',
count: 20,
selected: page,
animateChanges: false,
onSelected: (page) => selected.value = page,
),
),
),
),
),
),
);
Offset center(int page) => tester.getCenter(
find.byKey(ValueKey('page-indicator-dot-$page')),
);
final target = center(12);
final gesture = await tester.startGesture(center(10));
await gesture.moveTo(target);
await tester.pump();
expect(selected.value, 12);
for (var event = 0; event < 6; event++) {
// Vertical jitter emits new pointer events at an unchanged scrub x.
await gesture.moveTo(target + Offset(0, event.isEven ? 1 : 0));
await tester.pump();
expect(selected.value, 12, reason: 'Held pointer must not cascade');
expect(
center(12).dx,
target.dx,
reason: 'Rendered window stays under the pointer',
);
}
if (cancel) {
await gesture.cancel();
} else {
await gesture.up();
}
await tester.pump();
expect(selected.value, 12);
expect(
center(12).dx,
isNot(target.dx),
reason: 'Release recenters the window',
);
// The next gesture must snapshot the new window after end OR cancel.
final next = await tester.startGesture(center(12));
await next.moveTo(center(14));
await tester.pump();
expect(selected.value, 14);
await next.up();
await tester.pump();
await tester.tapAt(center(15));
await tester.pump();
expect(
selected.value,
15,
reason: 'A tap uses the current window once',
);
},
);
}
for (final window in [
(count: 3, selected: 1, first: 0, last: 2),
(count: 20, selected: 0, first: 0, last: 6),
(count: 20, selected: 10, first: 7, last: 13),
(count: 20, selected: 19, first: 13, last: 19),
]) {
testWidgets(
'visible dot centers select their pages in $direction $window',
(tester) async {
final selections = <int>[];
await tester.pumpWidget(
WidgetHelpers.testable(
child: Directionality(
textDirection: direction,
child: Center(
child: SizedBox(
width: 390,
child: PageIndicator(
semanticLabel: 'Photo',
count: window.count,
selected: window.selected,
animateChanges: false,
onSelected: selections.add,
),
),
),
),
),
);
for (var page = window.first; page <= window.last; page++) {
final dot = find.byKey(ValueKey('page-indicator-dot-$page'));
await tester.tapAt(tester.getCenter(dot));
await tester.pump();
expect(selections.last, page, reason: 'Tapped visible page $page');
}
final first = tester.getCenter(
find.byKey(ValueKey('page-indicator-dot-${window.first}')),
);
final last = tester.getCenter(
find.byKey(ValueKey('page-indicator-dot-${window.last}')),
);
await tester.dragFrom(first, last - first);
await tester.pump();
expect(selections.last, window.last);
},
);
}
}
}

View File

@@ -0,0 +1,20 @@
#!/bin/bash
# Test the actual UIKit paginator and Flutter bridge on a booted iOS simulator.
# Usage: /bin/bash scripts/test-mobile-pagination-accessibility.sh <simulator-udid>
set -euo pipefail
repo_root=$(cd "$(dirname "$0")/.." && pwd)
device=${1:?Pass a booted iOS simulator UDID}
flutter_root=$(sed -n 's/^FLUTTER_ROOT=//p' "$repo_root/mobile/ios/Flutter/Generated.xcconfig")
frameworks="$flutter_root/bin/cache/artifacts/engine/ios/Flutter.xcframework/ios-arm64_x86_64-simulator"
fixture=$(mktemp -d)
trap 'rm -r "$fixture"' EXIT
xcrun --sdk iphonesimulator swiftc \
-sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \
-target "$(uname -m)-apple-ios15.0-simulator" \
-F "$frameworks" -framework Flutter \
-Xlinker -rpath -Xlinker "$frameworks" \
"$repo_root/mobile/ios/Runner/ThemePaginationGeometry.swift" \
"$repo_root/mobile/ios/Runner/ThemePaginationGlassControl.swift" \
"$repo_root/mobile/ios/NativeControlTests/ThemePaginationAccessibilityTests.swift" \
-o "$fixture/pagination-accessibility-tests"
xcrun simctl spawn "$device" "$fixture/pagination-accessibility-tests"

View File

@@ -0,0 +1,11 @@
#!/bin/bash
# Compile the UIKit control's production geometry without Flutter or a simulator.
set -euo pipefail
repo_root=$(cd "$(dirname "$0")/.." && pwd)
fixture=$(mktemp -d)
trap 'rm -r "$fixture"' EXIT
xcrun swiftc \
"$repo_root/mobile/ios/Runner/ThemePaginationGeometry.swift" \
"$repo_root/mobile/ios/NativeControlTests/ThemePaginationGeometryTests.swift" \
-o "$fixture/pagination-tests"
"$fixture/pagination-tests"