mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
9
.github/workflows/_ci-clients.yml
vendored
9
.github/workflows/_ci-clients.yml
vendored
@@ -135,8 +135,17 @@ jobs:
|
||||
run: swift build -c release --package-path mobile/ios/BuzzPushKit
|
||||
- name: Test
|
||||
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
|
||||
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
|
||||
run: |
|
||||
/bin/bash scripts/test-mobile-gateway-recipes.sh
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */; };
|
||||
4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */; };
|
||||
4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */; };
|
||||
AC7400012F50000100A17E01 /* ThemePaginationGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7400022F50000100A17E01 /* ThemePaginationGeometry.swift */; };
|
||||
4A71C01F2F4100100A17E01 /* VoiceNotePackager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */; };
|
||||
4A71C0212F4110100A17E01 /* MP4Canonicalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0222F4110100A17E01 /* MP4Canonicalizer.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -283,6 +285,7 @@
|
||||
4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */,
|
||||
4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */,
|
||||
4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */,
|
||||
AC7400022F50000100A17E01 /* ThemePaginationGeometry.swift */,
|
||||
4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */,
|
||||
4A71C0222F4110100A17E01 /* MP4Canonicalizer.swift */,
|
||||
4A71C0242F4120100A17E01 /* NativeAttachmentPopoverMenu.swift */,
|
||||
@@ -616,6 +619,7 @@
|
||||
4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */,
|
||||
4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */,
|
||||
4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */,
|
||||
AC7400012F50000100A17E01 /* ThemePaginationGeometry.swift in Sources */,
|
||||
4A71C01F2F4100100A17E01 /* VoiceNotePackager.swift in Sources */,
|
||||
4A71C0212F4110100A17E01 /* MP4Canonicalizer.swift in Sources */,
|
||||
4A71C0232F4120100A17E01 /* NativeAttachmentPopoverMenu.swift in Sources */,
|
||||
|
||||
@@ -411,6 +411,8 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
|
||||
case "shutter": buttonIconName = "circle.fill"
|
||||
case "sun": buttonIconName = "sun.max"
|
||||
case "moon": buttonIconName = "moon"
|
||||
case "more": buttonIconName = "ellipsis"
|
||||
case "reply": buttonIconName = "arrowshape.turn.up.left"
|
||||
case "systemAppearance": buttonIconName = "circle.lefthalf.filled"
|
||||
case "colorSwatch": buttonIconName = "circle.fill"
|
||||
default: buttonIconName = "chevron.backward"
|
||||
|
||||
52
mobile/ios/Runner/ThemePaginationGeometry.swift
Normal file
52
mobile/ios/Runner/ThemePaginationGeometry.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -28,15 +28,15 @@ final class ThemePaginationGlassControlFactory: NSObject, FlutterPlatformViewFac
|
||||
}
|
||||
|
||||
private final class ThemePaginationControl: UIControl {
|
||||
private static let maximumVisibleDots = 7
|
||||
private static let fullDotSize: CGFloat = 6
|
||||
private static let fullDotSize = ThemePaginationGeometry.dotSize
|
||||
private static let selectedDotSize: CGFloat = 10
|
||||
private static let dotSpacing: CGFloat = 6
|
||||
private let glassView: UIVisualEffectView
|
||||
private let dotsContainer = UIView()
|
||||
private var glassHeightConstraint: NSLayoutConstraint?
|
||||
private var dots: [UIView] = []
|
||||
private var totalCount = 1
|
||||
private var selectedIndex = 0
|
||||
private var scrub = ThemePaginationScrub()
|
||||
private var activeColor = UIColor.label
|
||||
private var inactiveColor = UIColor.secondaryLabel.withAlphaComponent(0.32)
|
||||
var onSelectionChanged: ((Int) -> Void)?
|
||||
@@ -53,6 +53,7 @@ private final class ThemePaginationControl: UIControl {
|
||||
|
||||
backgroundColor = .clear
|
||||
isOpaque = false
|
||||
isAccessibilityElement = true
|
||||
accessibilityTraits = [.adjustable]
|
||||
accessibilityLabel = "Theme"
|
||||
|
||||
@@ -67,11 +68,13 @@ private final class ThemePaginationControl: UIControl {
|
||||
dotsContainer.isUserInteractionEnabled = false
|
||||
glassView.contentView.addSubview(dotsContainer)
|
||||
|
||||
let glassHeightConstraint = glassView.heightAnchor.constraint(equalToConstant: 30)
|
||||
self.glassHeightConstraint = glassHeightConstraint
|
||||
NSLayoutConstraint.activate([
|
||||
glassView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
glassView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
glassView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
glassView.heightAnchor.constraint(equalToConstant: 30),
|
||||
glassHeightConstraint,
|
||||
dotsContainer.leadingAnchor.constraint(equalTo: glassView.contentView.leadingAnchor, constant: 12),
|
||||
dotsContainer.trailingAnchor.constraint(equalTo: glassView.contentView.trailingAnchor, constant: -12),
|
||||
dotsContainer.topAnchor.constraint(equalTo: glassView.contentView.topAnchor),
|
||||
@@ -108,6 +111,15 @@ private final class ThemePaginationControl: UIControl {
|
||||
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
|
||||
if count != dots.count {
|
||||
rebuildDots(count: count)
|
||||
@@ -126,11 +138,39 @@ private final class ThemePaginationControl: UIControl {
|
||||
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) {
|
||||
guard totalCount > 0 else { return }
|
||||
let location = recognizer.location(in: glassView)
|
||||
let progress = max(0, min(0.999_999, location.x / max(1, glassView.bounds.width)))
|
||||
select(index: Int(progress * CGFloat(totalCount)))
|
||||
guard dotsContainer.bounds.width > 0 else { return }
|
||||
let location = recognizer.location(in: dotsContainer)
|
||||
if recognizer is UIPanGestureRecognizer {
|
||||
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) {
|
||||
@@ -163,20 +203,13 @@ private final class ThemePaginationControl: UIControl {
|
||||
|
||||
private func updateDots(animated: Bool) {
|
||||
guard !dots.isEmpty, dotsContainer.bounds.width > 0 else { return }
|
||||
let visibleCount = min(totalCount, Self.maximumVisibleDots)
|
||||
let maximumStart = max(0, totalCount - visibleCount)
|
||||
let centerSlot = visibleCount / 2
|
||||
let windowStart = min(max(0, selectedIndex - centerSlot), maximumStart)
|
||||
let windowEnd = windowStart + visibleCount - 1
|
||||
let hasEarlierDots = windowStart > 0
|
||||
let hasLaterDots = windowEnd < totalCount - 1
|
||||
let pitch = Self.fullDotSize + Self.dotSpacing
|
||||
let trackWidth = CGFloat(visibleCount) * Self.fullDotSize
|
||||
+ CGFloat(max(0, visibleCount - 1)) * Self.dotSpacing
|
||||
let trackOrigin = (dotsContainer.bounds.width - trackWidth) / 2
|
||||
let geometry = scrub.geometry ?? self.geometry
|
||||
let visibleCount = geometry.visibleCount
|
||||
let hasEarlierDots = geometry.isRTL ? geometry.hasLaterDots : geometry.hasEarlierDots
|
||||
let hasLaterDots = geometry.isRTL ? geometry.hasEarlierDots : geometry.hasLaterDots
|
||||
let changes = {
|
||||
for (page, dot) in self.dots.enumerated() {
|
||||
let slot = page - windowStart
|
||||
let slot = geometry.slot(for: page)
|
||||
let isVisible = (0..<visibleCount).contains(slot)
|
||||
var diameter = Self.fullDotSize
|
||||
if page == self.selectedIndex {
|
||||
@@ -191,7 +224,7 @@ private final class ThemePaginationControl: UIControl {
|
||||
diameter = 4
|
||||
}
|
||||
dot.center = CGPoint(
|
||||
x: trackOrigin + Self.fullDotSize / 2 + CGFloat(slot) * pitch,
|
||||
x: geometry.centerX(for: page),
|
||||
y: self.dotsContainer.bounds.midY
|
||||
)
|
||||
dot.alpha = isVisible ? 1 : 0
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/physics.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
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/theme/theme.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_navigation_bar.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/route_transition.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_zoom_surface.dart';
|
||||
|
||||
const _imageViewerPushDuration = Duration(milliseconds: 260);
|
||||
const _imageViewerPopDuration = Duration(milliseconds: 170);
|
||||
@@ -191,7 +197,6 @@ class MediaImageViewerPage extends HookConsumerWidget {
|
||||
static const _dismissThreshold = 100.0;
|
||||
static const _dismissVelocity = 700.0;
|
||||
static const _backgroundFadeDivisor = 300.0;
|
||||
static const _filmstripScrubExtent = 44.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -210,7 +215,6 @@ class MediaImageViewerPage extends HookConsumerWidget {
|
||||
final safeInitialIndex = initialIndex.clamp(0, images.length - 1).toInt();
|
||||
final currentIndex = useState(safeInitialIndex);
|
||||
final pageController = usePageController(initialPage: safeInitialIndex);
|
||||
final pagePosition = useState(safeInitialIndex.toDouble());
|
||||
final transformationControllers = useMemoized(
|
||||
() => [
|
||||
for (var index = 0; index < images.length; index++)
|
||||
@@ -231,16 +235,6 @@ class MediaImageViewerPage extends HookConsumerWidget {
|
||||
final dragOffset = useState(0.0);
|
||||
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) {
|
||||
if (index != currentIndex.value) return;
|
||||
final nextIsTransformed = _hasImageTransform(
|
||||
@@ -257,11 +251,6 @@ class MediaImageViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
pageController.addListener(handlePagePositionChanged);
|
||||
return () => pageController.removeListener(handlePagePositionChanged);
|
||||
}, [pageController]);
|
||||
|
||||
useEffect(() {
|
||||
final listeners = <VoidCallback>[];
|
||||
for (var index = 0; index < transformationControllers.length; index++) {
|
||||
@@ -299,6 +288,9 @@ class MediaImageViewerPage extends HookConsumerWidget {
|
||||
}, [zoomResetController]);
|
||||
|
||||
void onPageChanged(int index) {
|
||||
if (index != currentIndex.value) {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
}
|
||||
_precacheViewerImages(context, images, index);
|
||||
currentIndex.value = index;
|
||||
isTransformed.value = _hasImageTransform(
|
||||
@@ -309,34 +301,6 @@ class MediaImageViewerPage extends HookConsumerWidget {
|
||||
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) {
|
||||
if (images[index].previewDecodeWidth == null ||
|
||||
fullResolutionIndices.value.contains(index)) {
|
||||
@@ -528,12 +492,8 @@ class MediaImageViewerPage extends HookConsumerWidget {
|
||||
onPageChanged: onPageChanged,
|
||||
itemBuilder: (context, index) {
|
||||
final image = images[index];
|
||||
final viewPadding = MediaQuery.viewPaddingOf(context);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: viewPadding.top + 48 + Grid.xxs,
|
||||
bottom: viewPadding.bottom + 56 + (Grid.xxs * 2),
|
||||
),
|
||||
padding: _mediaViewerPadding(context),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final viewerSize = _imageViewerSize(
|
||||
@@ -621,9 +581,6 @@ class MediaImageViewerPage extends HookConsumerWidget {
|
||||
child: _MediaViewerBottomControls(
|
||||
images: images,
|
||||
currentIndex: currentIndex.value,
|
||||
pagePosition: pagePosition,
|
||||
onScrubUpdate: onFilmstripScrubUpdate,
|
||||
onScrubEnd: onFilmstripScrubEnd,
|
||||
onSelect: (index) {
|
||||
if (index == currentIndex.value) return;
|
||||
if (MediaQuery.disableAnimationsOf(context)) {
|
||||
|
||||
@@ -3,9 +3,6 @@ part of '../media_viewer_page.dart';
|
||||
class _MediaViewerBottomControls extends StatelessWidget {
|
||||
final List<MediaViewerImage> images;
|
||||
final int currentIndex;
|
||||
final ValueListenable<double> pagePosition;
|
||||
final ValueChanged<double> onScrubUpdate;
|
||||
final VoidCallback onScrubEnd;
|
||||
final ValueChanged<int> onSelect;
|
||||
final VoidCallback? onReply;
|
||||
final VoidCallback? onMore;
|
||||
@@ -13,9 +10,6 @@ class _MediaViewerBottomControls extends StatelessWidget {
|
||||
const _MediaViewerBottomControls({
|
||||
required this.images,
|
||||
required this.currentIndex,
|
||||
required this.pagePosition,
|
||||
required this.onScrubUpdate,
|
||||
required this.onScrubEnd,
|
||||
required this.onSelect,
|
||||
required this.onReply,
|
||||
required this.onMore,
|
||||
@@ -37,14 +31,19 @@ class _MediaViewerBottomControls extends StatelessWidget {
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: images.length > 1
|
||||
? _MediaViewerFilmstrip(
|
||||
key: const ValueKey('message-media-image-viewer-filmstrip'),
|
||||
images: images,
|
||||
currentIndex: currentIndex,
|
||||
pagePosition: pagePosition,
|
||||
onScrubUpdate: onScrubUpdate,
|
||||
onScrubEnd: onScrubEnd,
|
||||
onSelect: onSelect,
|
||||
? Theme(
|
||||
data: ThemeData.dark(),
|
||||
child: PageIndicator(
|
||||
key: const ValueKey(
|
||||
'message-media-image-viewer-pagination',
|
||||
),
|
||||
semanticLabel: 'Image',
|
||||
containerHeight: 48,
|
||||
count: images.length,
|
||||
selected: currentIndex,
|
||||
animateChanges: !MediaQuery.disableAnimationsOf(context),
|
||||
onSelected: onSelect,
|
||||
),
|
||||
)
|
||||
: 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 {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
@@ -240,6 +74,22 @@ class _MediaViewerCircleButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
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(
|
||||
dimension: 48,
|
||||
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) {
|
||||
if (aspectRatio == null || aspectRatio <= 0) {
|
||||
return viewport;
|
||||
|
||||
@@ -1,39 +1,47 @@
|
||||
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 {
|
||||
final VideoPlayerController? controller;
|
||||
final VoidCallback? onReply;
|
||||
|
||||
const _VideoViewerBottomControls({
|
||||
required this.controller,
|
||||
required this.onReply,
|
||||
required this.onInteractionStart,
|
||||
required this.onInteractionEnd,
|
||||
});
|
||||
|
||||
final VideoPlayerController? controller;
|
||||
final VoidCallback? onReply;
|
||||
final VoidCallback onInteractionStart;
|
||||
final VoidCallback onInteractionEnd;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final readyController = controller?.value.isInitialized == true
|
||||
? controller
|
||||
: null;
|
||||
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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (readyController != null)
|
||||
_VideoTransportBar(controller: readyController),
|
||||
Row(
|
||||
children: [
|
||||
_MediaViewerCircleButton(
|
||||
_VideoTransportBar(
|
||||
controller: readyController,
|
||||
onReply: onReply,
|
||||
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'),
|
||||
icon: LucideIcons.messageSquareReply,
|
||||
tooltip: 'Reply in thread',
|
||||
onPressed: onReply,
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -41,9 +49,17 @@ class _VideoViewerBottomControls extends StatelessWidget {
|
||||
}
|
||||
|
||||
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
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -52,78 +68,341 @@ class _VideoTransportBar extends HookConsumerWidget {
|
||||
final durationMs = value.duration.inMilliseconds;
|
||||
final positionMs = value.position.inMilliseconds.clamp(0, durationMs);
|
||||
final hasDuration = durationMs > 0;
|
||||
final scrubPosition = useState<double?>(null);
|
||||
final mutedVolume = useRef(1.0);
|
||||
final scrubGeneration = useRef(0);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: Grid.xxs),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.42),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.12)),
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
key: const ValueKey('message-media-video-viewer-play-pause'),
|
||||
void beginScrub(double next) {
|
||||
scrubGeneration.value++;
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
onInteractionStart();
|
||||
scrubPosition.value = next;
|
||||
}
|
||||
|
||||
Future<void> finishScrub(double next) async {
|
||||
final generation = scrubGeneration.value;
|
||||
bool isCurrent() =>
|
||||
context.mounted && generation == scrubGeneration.value;
|
||||
try {
|
||||
await controller.seekTo(Duration(milliseconds: next.round()));
|
||||
} 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: () {
|
||||
if (value.isPlaying) {
|
||||
controller.pause();
|
||||
} else {
|
||||
controller.play();
|
||||
}
|
||||
if (!isCurrent()) return;
|
||||
beginScrub(next);
|
||||
unawaited(finishScrub(next));
|
||||
},
|
||||
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,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
activeTrackColor: Colors.white,
|
||||
inactiveTrackColor: Colors.white.withValues(alpha: 0.28),
|
||||
thumbColor: Colors.white,
|
||||
overlayColor: Colors.white.withValues(alpha: 0.12),
|
||||
trackHeight: 3,
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 5,
|
||||
),
|
||||
),
|
||||
child: Slider(
|
||||
value: hasDuration ? positionMs.toDouble() : 0,
|
||||
min: 0,
|
||||
max: hasDuration ? durationMs.toDouble() : 1,
|
||||
onChanged: hasDuration
|
||||
? (next) => controller.seekTo(
|
||||
Duration(milliseconds: next.round()),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatVideoDuration(value.duration),
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.78),
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
scrubPosition.value = null;
|
||||
onInteractionEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final displayedMs = scrubPosition.value?.round() ?? positionMs;
|
||||
final currentTime = _formatVideoDuration(
|
||||
Duration(milliseconds: displayedMs),
|
||||
);
|
||||
final totalTime = _formatVideoDuration(value.duration);
|
||||
final timeStyle = context.textTheme.labelSmall!.copyWith(
|
||||
color: Colors.white,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
);
|
||||
final speedStyle = context.textTheme.labelMedium!.copyWith(
|
||||
color: Colors.white,
|
||||
);
|
||||
final textScaler = MediaQuery.textScalerOf(context);
|
||||
final textDirection = Directionality.of(context);
|
||||
final labelSizes = useMemoized(() {
|
||||
Size measure(String text, TextStyle style) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: text, style: style),
|
||||
textDirection: textDirection,
|
||||
textScaler: textScaler,
|
||||
)..layout();
|
||||
final size = painter.size;
|
||||
painter.dispose();
|
||||
return size;
|
||||
}
|
||||
|
||||
return (
|
||||
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) {
|
||||
return '$hours:${minutes.toString().padLeft(2, '0')}:$paddedSeconds';
|
||||
}
|
||||
return '$minutes:$paddedSeconds';
|
||||
return '${minutes.toString().padLeft(2, '0')}:$paddedSeconds';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -280,6 +280,8 @@ class MediaVideoViewerPage extends HookConsumerWidget {
|
||||
callback();
|
||||
}
|
||||
|
||||
final controls = _useVideoControlsVisibility(context, controller.value);
|
||||
|
||||
final viewportHeight = MediaQuery.sizeOf(context).height;
|
||||
final dragProgress = (dragOffset.value / viewportHeight).clamp(0.0, 1.0);
|
||||
final videoScale = 1 - (dragProgress * 0.1);
|
||||
@@ -299,22 +301,24 @@ class MediaVideoViewerPage extends HookConsumerWidget {
|
||||
offset: Offset(0, dragOffset.value),
|
||||
child: Transform.scale(
|
||||
scale: videoScale,
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('message-media-video-viewer-gesture'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onVerticalDragStart: (_) {
|
||||
child: _VideoZoomSurface(
|
||||
key: ValueKey(videoUrl),
|
||||
onTap: controls.toggle,
|
||||
controlsVisible: controls.visible,
|
||||
onInteractionStart: controls.beginInteraction,
|
||||
onInteractionEnd: controls.endInteraction,
|
||||
onDismissStart: () {
|
||||
snapBackController.stop();
|
||||
isDragging.value = true;
|
||||
},
|
||||
onVerticalDragUpdate: (details) {
|
||||
onDismissUpdate: (delta) {
|
||||
if (!isDragging.value) return;
|
||||
dragOffset.value = (dragOffset.value + details.delta.dy)
|
||||
dragOffset.value = (dragOffset.value + delta)
|
||||
.clamp(0.0, viewportHeight)
|
||||
.toDouble();
|
||||
},
|
||||
onVerticalDragEnd: (details) {
|
||||
onDismissEnd: (velocity) {
|
||||
isDragging.value = false;
|
||||
final velocity = details.primaryVelocity ?? 0;
|
||||
if (dragOffset.value > _dismissThreshold ||
|
||||
velocity > _dismissVelocity) {
|
||||
controller.value?.pause();
|
||||
@@ -323,8 +327,13 @@ class MediaVideoViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
animateSnapBack();
|
||||
},
|
||||
onVerticalDragCancel: animateSnapBack,
|
||||
child: SafeArea(
|
||||
onDismissCancel: () {
|
||||
snapBackController.stop();
|
||||
isDragging.value = false;
|
||||
dragOffset.value = 0;
|
||||
},
|
||||
child: Padding(
|
||||
padding: _mediaViewerPadding(context),
|
||||
child: Center(
|
||||
child: FutureBuilder<void>(
|
||||
future: initializeFuture.value,
|
||||
@@ -362,8 +371,9 @@ class MediaVideoViewerPage extends HookConsumerWidget {
|
||||
height:
|
||||
MediaQuery.paddingOf(context).top +
|
||||
IosNavigationMetrics.of(context).compactHeight,
|
||||
child: Opacity(
|
||||
opacity: chromeOpacity,
|
||||
child: _VideoViewerChrome(
|
||||
visible: controls.visible,
|
||||
dragOpacity: chromeOpacity,
|
||||
child: Theme(
|
||||
data: ThemeData.dark(),
|
||||
child: IosNavigationBar(
|
||||
@@ -383,8 +393,9 @@ class MediaVideoViewerPage extends HookConsumerWidget {
|
||||
PositionedDirectional(
|
||||
top: Grid.sm,
|
||||
end: Grid.sm,
|
||||
child: Opacity(
|
||||
opacity: chromeOpacity,
|
||||
child: _VideoViewerChrome(
|
||||
visible: controls.visible,
|
||||
dragOpacity: chromeOpacity,
|
||||
child: SafeArea(
|
||||
child: _MediaViewerCloseButton(
|
||||
key: const ValueKey('message-media-video-viewer-close'),
|
||||
@@ -398,11 +409,15 @@ class MediaVideoViewerPage extends HookConsumerWidget {
|
||||
bottom: 0,
|
||||
start: 0,
|
||||
end: 0,
|
||||
child: Opacity(
|
||||
opacity: chromeOpacity,
|
||||
child: _VideoViewerChrome(
|
||||
key: const ValueKey('message-media-video-viewer-controls'),
|
||||
visible: controls.visible,
|
||||
dragOpacity: chromeOpacity,
|
||||
child: SafeArea(
|
||||
child: _VideoViewerBottomControls(
|
||||
controller: controller.value,
|
||||
onInteractionStart: controls.beginInteraction,
|
||||
onInteractionEnd: controls.endInteraction,
|
||||
onReply: onReply == null
|
||||
? null
|
||||
: () => unawaited(replyInThread()),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -275,90 +275,93 @@ void showImageActions({
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
title: 'Image',
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: IconTheme.merge(
|
||||
data: const IconThemeData(size: 22),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SheetActionSection(
|
||||
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)
|
||||
child: SingleChildScrollView(
|
||||
child: IconTheme.merge(
|
||||
data: const IconThemeData(size: 22),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SheetActionSection(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
LucideIcons.trash2,
|
||||
color: sheetContext.colors.error,
|
||||
),
|
||||
title: Text(
|
||||
'Delete message',
|
||||
style: TextStyle(color: sheetContext.colors.error),
|
||||
),
|
||||
leading: const Icon(LucideIcons.download),
|
||||
title: const Text('Save image'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_confirmDelete(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: channelId,
|
||||
messageId: message.id,
|
||||
onDeleted: onDeleted,
|
||||
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(
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -123,7 +123,6 @@ class _MessageImageCarousel extends HookConsumerWidget {
|
||||
() => [for (var index = 0; index < items.length; index++) Object()],
|
||||
[itemSignature],
|
||||
);
|
||||
final controller = usePageController(viewportFraction: 0.9);
|
||||
final currentIndex = useState(0);
|
||||
final mediaAuth = ref.watch(mediaGetAuthServiceProvider);
|
||||
final mediaClient = ref.watch(mediaHttpClientProvider);
|
||||
@@ -161,8 +160,7 @@ class _MessageImageCarousel extends HookConsumerWidget {
|
||||
for (var index = 0; index < items.length; index++)
|
||||
math.max(
|
||||
1.0,
|
||||
carouselWidth * controller.viewportFraction -
|
||||
itemTrailingPaddings[index],
|
||||
carouselWidth * 0.9 - itemTrailingPaddings[index],
|
||||
),
|
||||
];
|
||||
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
|
||||
@@ -193,16 +191,16 @@ class _MessageImageCarousel extends HookConsumerWidget {
|
||||
key: const ValueKey('message-media-carousel'),
|
||||
width: carouselWidth,
|
||||
height: _messageMediaCarouselHeight,
|
||||
child: PageView.builder(
|
||||
controller: controller,
|
||||
allowImplicitScrolling: true,
|
||||
// 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,
|
||||
child: _MessageCarouselViewport(
|
||||
width: carouselWidth,
|
||||
currentIndex: currentIndex.value,
|
||||
itemCount: items.length,
|
||||
onPageChanged: (index) => currentIndex.value = index,
|
||||
onPageChanged: (index) {
|
||||
if (index != currentIndex.value) {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
}
|
||||
currentIndex.value = index;
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../../shared/widgets/ios_glass_navigation_action.dart';
|
||||
import '../../shared/widgets/ios_glass_navigation_button.dart';
|
||||
import '../../shared/widgets/ios_glass_theme_pagination.dart';
|
||||
import '../../shared/widgets/page_indicator.dart';
|
||||
import 'theme_option_sheets.dart';
|
||||
|
||||
part 'theme_picker_page/theme_home_preview.dart';
|
||||
|
||||
@@ -3,7 +3,6 @@ part of '../theme_picker_page.dart';
|
||||
const _appearanceOrder = [ThemeMode.light, ThemeMode.dark, ThemeMode.system];
|
||||
const _appearanceMotionDuration = Duration(milliseconds: 150);
|
||||
const _appearanceMotionCurve = Cubic(0.23, 1, 0.32, 1);
|
||||
const _iosThemeScrubberWidth = 116.0;
|
||||
|
||||
class _ThemePreviewExperience extends HookConsumerWidget {
|
||||
const _ThemePreviewExperience();
|
||||
@@ -231,7 +230,10 @@ class _ThemePreviewExperience extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _ThemeScrubber(
|
||||
child: PageIndicator(
|
||||
semanticLabel: 'Theme',
|
||||
controlKey: const ValueKey('theme-preview-scrubber'),
|
||||
dotKeyPrefix: 'theme-pagination-dot-',
|
||||
count: entries.length,
|
||||
selected: currentPage.value.clamp(0, entries.length - 1),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,6 +596,8 @@ IosNavigationAction? _nativeAction(Widget? widget) {
|
||||
}
|
||||
if (widget is IosGlassNavigationButton) {
|
||||
final symbol = switch (widget.icon) {
|
||||
IosGlassNavigationIcon.more => 'ellipsis',
|
||||
IosGlassNavigationIcon.reply => 'arrowshape.turn.up.left',
|
||||
IosGlassNavigationIcon.back => 'chevron.backward',
|
||||
IosGlassNavigationIcon.close => 'xmark',
|
||||
IosGlassNavigationIcon.camera => 'camera',
|
||||
|
||||
@@ -25,6 +25,8 @@ enum IosGlassNavigationIcon {
|
||||
sun,
|
||||
moon,
|
||||
systemAppearance,
|
||||
more,
|
||||
reply,
|
||||
}
|
||||
|
||||
/// Leading width used by iOS channel-style headers.
|
||||
@@ -226,6 +228,8 @@ class IosGlassNavigationButton extends HookWidget {
|
||||
)
|
||||
: Icon(
|
||||
switch (icon) {
|
||||
IosGlassNavigationIcon.more => Icons.more_horiz,
|
||||
IosGlassNavigationIcon.reply => Icons.reply,
|
||||
IosGlassNavigationIcon.back =>
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
IosGlassNavigationIcon.close =>
|
||||
|
||||
@@ -11,6 +11,8 @@ class IosGlassThemePagination extends HookWidget {
|
||||
/// Creates a native theme-pagination control.
|
||||
const IosGlassThemePagination({
|
||||
super.key,
|
||||
this.semanticLabel = 'Theme',
|
||||
this.containerHeight = 30,
|
||||
required this.count,
|
||||
required this.selected,
|
||||
required this.animateChanges,
|
||||
@@ -21,6 +23,12 @@ class IosGlassThemePagination extends HookWidget {
|
||||
|
||||
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 selected;
|
||||
final bool animateChanges;
|
||||
@@ -33,6 +41,7 @@ class IosGlassThemePagination extends HookWidget {
|
||||
assert(defaultTargetPlatform == TargetPlatform.iOS);
|
||||
final nativeChannel = useState<MethodChannel?>(null);
|
||||
final onSelectedRef = useRef(onSelected)..value = onSelected;
|
||||
final isRTL = Directionality.of(context) == TextDirection.rtl;
|
||||
final brightness = Theme.of(context).brightness.name;
|
||||
final activeColorValue = activeColor.toARGB32();
|
||||
final inactiveColorValue = inactiveColor.toARGB32();
|
||||
@@ -54,6 +63,9 @@ class IosGlassThemePagination extends HookWidget {
|
||||
if (channel != null) {
|
||||
unawaited(
|
||||
channel.invokeMethod<void>('setState', <String, Object>{
|
||||
'accessibilityLabel': semanticLabel,
|
||||
'isRTL': isRTL,
|
||||
'containerHeight': containerHeight,
|
||||
'count': count,
|
||||
'selected': selected,
|
||||
'animateChanges': animateChanges,
|
||||
@@ -67,6 +79,9 @@ class IosGlassThemePagination extends HookWidget {
|
||||
},
|
||||
[
|
||||
nativeChannel.value,
|
||||
semanticLabel,
|
||||
isRTL,
|
||||
containerHeight,
|
||||
count,
|
||||
selected,
|
||||
animateChanges,
|
||||
@@ -80,6 +95,9 @@ class IosGlassThemePagination extends HookWidget {
|
||||
viewType: viewType,
|
||||
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
|
||||
creationParams: <String, Object>{
|
||||
'accessibilityLabel': semanticLabel,
|
||||
'isRTL': isRTL,
|
||||
'containerHeight': containerHeight,
|
||||
'count': count,
|
||||
'selected': selected,
|
||||
'animateChanges': animateChanges,
|
||||
|
||||
273
mobile/lib/shared/widgets/page_indicator.dart
Normal file
273
mobile/lib/shared/widgets/page_indicator.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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:ui' as ui;
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -1883,9 +1885,32 @@ void main() {
|
||||
|
||||
expect(find.text('Delete message'), findsOneWidget);
|
||||
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', () {
|
||||
test('preserves gif file extensions', () {
|
||||
expect(
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/rendering.dart' show RenderRepaintBoundary;
|
||||
|
||||
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/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -1673,29 +1678,16 @@ Photos
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-media-image-viewer-filmstrip')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(
|
||||
const ValueKey('message-media-image-viewer-thumbnail:1'),
|
||||
),
|
||||
find.byKey(const ValueKey('message-media-image-viewer-pagination')),
|
||||
findsOneWidget,
|
||||
);
|
||||
final displayedImage = tester.widget<MediaImage>(
|
||||
find.byKey(const ValueKey('message-media-image-viewer-image:1')),
|
||||
);
|
||||
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(
|
||||
selectedThumbnailRadius.topLeft.x,
|
||||
closeTo(Radii.sm - 2.5, 0.01),
|
||||
tester.widget<PageIndicator>(find.byType(PageIndicator)).selected,
|
||||
1,
|
||||
);
|
||||
|
||||
await tester.fling(
|
||||
@@ -1705,111 +1697,203 @@ Photos
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final thirdThumbnail = find.byKey(
|
||||
const ValueKey('message-media-image-viewer-thumbnail:2'),
|
||||
expect(
|
||||
tester.widget<PageIndicator>(find.byType(PageIndicator)).selected,
|
||||
2,
|
||||
);
|
||||
final thirdSemantics = tester.widget<Semantics>(
|
||||
find
|
||||
.ancestor(of: thirdThumbnail, matching: find.byType(Semantics))
|
||||
.first,
|
||||
);
|
||||
expect(thirdSemantics.properties.selected, isTrue);
|
||||
expect(find.bySemanticsLabel('Image 3 of 3'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'keeps adjacent carousel images active and ends with a gutter',
|
||||
'paints neighboring photos through the avatar gutter after paging',
|
||||
(tester) async {
|
||||
const first = 'https://example.com/media/gutter-one.png';
|
||||
const second = 'https://example.com/media/gutter-two.png';
|
||||
const connectivity = MethodChannel(
|
||||
'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(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content:
|
||||
'''
|
||||

|
||||

|
||||
''',
|
||||
tags: [
|
||||
['imeta', 'url $first', 'm image/png'],
|
||||
['imeta', 'url $second', 'm image/png'],
|
||||
],
|
||||
RepaintBoundary(
|
||||
key: boundaryKey,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 64, right: 16),
|
||||
child: MessageContent(
|
||||
mediaCarouselTrailingOverflow: 16,
|
||||
content: urls.map((url) => '').join('\n'),
|
||||
tags: [
|
||||
for (final url in urls)
|
||||
['imeta', 'url $url', 'm image/png'],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final carousel = find.byKey(const ValueKey('message-media-carousel'));
|
||||
final pageViewFinder = find.descendant(
|
||||
of: carousel,
|
||||
matching: find.byType(PageView),
|
||||
final scroll = tester.widget<CustomScrollView>(
|
||||
find.descendant(
|
||||
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);
|
||||
expect(pageView.clipBehavior, Clip.none);
|
||||
Future<List<int>> pixel(int x) async {
|
||||
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();
|
||||
// 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(
|
||||
const ValueKey('message-media-carousel-item:$second'),
|
||||
controller.jumpToPage(2);
|
||||
await tester.pumpAndSettle();
|
||||
final last = find.byKey(
|
||||
ValueKey('message-media-carousel-item:${urls[2]}'),
|
||||
);
|
||||
expect(
|
||||
tester.getRect(carousel).right - tester.getRect(lastCard).right,
|
||||
Grid.gutter,
|
||||
tester.getRect(carousel).right - tester.getRect(last).right,
|
||||
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(
|
||||
'jumps to a selected gallery thumbnail when motion is disabled',
|
||||
(tester) async {
|
||||
const first = 'https://example.com/media/reduced-motion-one.png';
|
||||
const second = 'https://example.com/media/reduced-motion-two.png';
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content:
|
||||
'''
|
||||
testWidgets('jumps to a selected gallery dot when motion is disabled', (
|
||||
tester,
|
||||
) async {
|
||||
const first = 'https://example.com/media/reduced-motion-one.png';
|
||||
const second = 'https://example.com/media/reduced-motion-two.png';
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content:
|
||||
'''
|
||||

|
||||

|
||||
''',
|
||||
tags: [
|
||||
['imeta', 'url $first', 'm image/png'],
|
||||
['imeta', 'url $second', 'm image/png'],
|
||||
],
|
||||
),
|
||||
disableAnimations: true,
|
||||
tags: [
|
||||
['imeta', 'url $first', 'm image/png'],
|
||||
['imeta', 'url $second', 'm image/png'],
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
disableAnimations: true,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('message-media-carousel-item:$first')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.byKey(
|
||||
const ValueKey('message-media-image-viewer-thumbnail:1'),
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('message-media-carousel-item:$first')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final indicatorFinder = find.byType(PageIndicator);
|
||||
await tester.tap(find.byKey(const ValueKey('page-indicator-dot-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();
|
||||
|
||||
final selectedThumbnail = tester.widget<Semantics>(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.byKey(
|
||||
const ValueKey('message-media-image-viewer-thumbnail:1'),
|
||||
),
|
||||
matching: find.byType(Semantics),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
expect(selectedThumbnail.properties.selected, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final views = tester.widgetList<UiKitView>(find.byType(UiKitView));
|
||||
final pagination = views.singleWhere(
|
||||
(view) => view.viewType == 'buzz/theme_pagination_glass',
|
||||
);
|
||||
final params = pagination.creationParams! as Map<String, Object>;
|
||||
expect(params['accessibilityLabel'], 'Image');
|
||||
expect(params['containerHeight'], 48);
|
||||
expect(params['brightness'], 'dark');
|
||||
expect(params['count'], 2);
|
||||
expect(params['selected'], 0);
|
||||
final buttons = views.where(
|
||||
(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', (
|
||||
tester,
|
||||
@@ -1835,10 +1919,10 @@ Photos
|
||||
|
||||
await tester.pumpWidget(gallery(firstGallery));
|
||||
await tester.pumpAndSettle();
|
||||
final firstCarousel = tester.widget<PageView>(
|
||||
final firstCarousel = tester.widget<CustomScrollView>(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('message-media-carousel')),
|
||||
matching: find.byType(PageView),
|
||||
matching: find.byType(CustomScrollView),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1848,14 +1932,17 @@ Photos
|
||||
1200,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(firstCarousel.controller!.page, greaterThan(0));
|
||||
expect(
|
||||
(firstCarousel.controller! as PageController).page,
|
||||
greaterThan(0),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(gallery(secondGallery));
|
||||
await tester.pumpAndSettle();
|
||||
final secondCarousel = tester.widget<PageView>(
|
||||
final secondCarousel = tester.widget<CustomScrollView>(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('message-media-carousel')),
|
||||
matching: find.byType(PageView),
|
||||
matching: find.byType(CustomScrollView),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1863,7 +1950,7 @@ Photos
|
||||
secondCarousel.controller,
|
||||
isNot(same(firstCarousel.controller)),
|
||||
);
|
||||
expect(secondCarousel.controller!.page, 0);
|
||||
expect((secondCarousel.controller! as PageController).page, 0);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
|
||||
@@ -666,12 +666,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(selectionHaptics, 1);
|
||||
|
||||
final scrubber = tester.getRect(
|
||||
find.byKey(const ValueKey('theme-preview-scrubber')),
|
||||
);
|
||||
await tester.tapAt(
|
||||
Offset(scrubber.left + scrubber.width * 0.75, scrubber.center.dy),
|
||||
);
|
||||
await tester.tap(find.byKey(const ValueKey('theme-pagination-dot-1')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(selectionHaptics, 2);
|
||||
});
|
||||
|
||||
279
mobile/test/shared/widgets/page_indicator_test.dart
Normal file
279
mobile/test/shared/widgets/page_indicator_test.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
scripts/test-mobile-pagination-accessibility.sh
Normal file
20
scripts/test-mobile-pagination-accessibility.sh
Normal 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"
|
||||
11
scripts/test-mobile-pagination.sh
Normal file
11
scripts/test-mobile-pagination.sh
Normal 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"
|
||||
Reference in New Issue
Block a user