Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Resell.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
C6235EDD2FA1587E00395FD7 /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6235EDB2FA1587E00395FD7 /* SemanticVersion.swift */; };
C6235EDE2FA1587E00395FD7 /* AppVersionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6235EDA2FA1587E00395FD7 /* AppVersionService.swift */; };
C6F1BDBB2FA14CC1004886F8 /* ForceUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F1BDBA2FA14CC1004886F8 /* ForceUpdateView.swift */; };
ADDB8A9D2CC0738E02F4CFA5 /* GlassToolbarModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1309285CE00E73FFD36889 /* GlassToolbarModifier.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -324,6 +325,7 @@
C6235EDA2FA1587E00395FD7 /* AppVersionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionService.swift; sourceTree = "<group>"; };
C6235EDB2FA1587E00395FD7 /* SemanticVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemanticVersion.swift; sourceTree = "<group>"; };
C6F1BDBA2FA14CC1004886F8 /* ForceUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForceUpdateView.swift; sourceTree = "<group>"; };
AD1309285CE00E73FFD36889 /* GlassToolbarModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassToolbarModifier.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -598,6 +600,7 @@
2C9B4D052C8FCAF20029DF61 /* Components */ = {
isa = PBXGroup;
children = (
AD1309285CE00E73FFD36889 /* GlassToolbarModifier.swift */,
C6F1BDBA2FA14CC1004886F8 /* ForceUpdateView.swift */,
C607480A2F90643200825192 /* ShareSheet.swift */,
2EEAAB272F1ADA1B0006FF5C /* AvailabilitySettingsMenu.swift */,
Expand Down Expand Up @@ -900,6 +903,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
ADDB8A9D2CC0738E02F4CFA5 /* GlassToolbarModifier.swift in Sources */,
2E87F6FE2F29A651007C228E /* TransactionConfirmationPopup.swift in Sources */,
2E87F6FC2F270A13007C228E /* NotificationsSettingsView.swift in Sources */,
2E87F6FB2F270A0D007C228E /* NotificationsViewModel.swift in Sources */,
Expand Down
93 changes: 93 additions & 0 deletions Resell/Utils/Extensions/UIImage + Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,99 @@ extension UIImage {
}
return UIImage()
}()

/// Whether the toolbar band is *clearly* dark enough for white icons.
/// Mixed / mid-tone heroes (drinks, cafe walls, snow) stay on black.
func prefersLightToolbarIcons(
displayedIn containerSize: CGSize,
darkThreshold: CGFloat = 0.38,
requiredDarkFraction: CGFloat = 0.78
) -> Bool {
guard let samples = toolbarBandLuminanceSamples(in: containerSize),
!samples.isEmpty else {
return false
}
let darkCount = samples.filter { $0 < darkThreshold }.count
return CGFloat(darkCount) / CGFloat(samples.count) >= requiredDarkFraction
}

/// Grid of relative luminances (0 = black, 1 = white) from the visible
/// top toolbar band after aspect-fill cropping and orientation fix.
func toolbarBandLuminanceSamples(in containerSize: CGSize) -> [CGFloat]? {
guard containerSize.width > 0, containerSize.height > 0,
let upright = flattenedOrientation().cgImage else { return nil }

let imageSize = CGSize(width: upright.width, height: upright.height)
let visible = aspectFillVisibleRect(imageSize: imageSize, containerSize: containerSize)
// Cover both leading + trailing toolbar items with a short top strip.
let sample = CGRect(
x: visible.minX,
y: visible.minY,
width: max(1, visible.width),
height: max(1, visible.height * 0.14)
).integral

guard let cropped = upright.cropping(to: sample) else { return nil }

let gridWidth = 16
let gridHeight = 4
let colorSpace = CGColorSpaceCreateDeviceRGB()
var pixels = [UInt8](repeating: 0, count: gridWidth * gridHeight * 4)
guard let context = CGContext(
data: &pixels,
width: gridWidth,
height: gridHeight,
bitsPerComponent: 8,
bytesPerRow: gridWidth * 4,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }

context.interpolationQuality = .low
context.draw(cropped, in: CGRect(x: 0, y: 0, width: gridWidth, height: gridHeight))

return stride(from: 0, to: pixels.count, by: 4).map { offset in
let r = CGFloat(pixels[offset]) / 255
let g = CGFloat(pixels[offset + 1]) / 255
let b = CGFloat(pixels[offset + 2]) / 255
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
}

/// Draw the image upright so EXIF orientation doesn't shift the sampled corner.
func flattenedOrientation() -> UIImage {
if imageOrientation == .up { return self }
let format = UIGraphicsImageRendererFormat.default()
format.scale = scale
format.opaque = false
return UIGraphicsImageRenderer(size: size, format: format).image { _ in
draw(in: CGRect(origin: .zero, size: size))
}
}

/// Pixel rect of `imageSize` that remains visible when aspect-filled into `containerSize`.
private func aspectFillVisibleRect(imageSize: CGSize, containerSize: CGSize) -> CGRect {
let imageAspect = imageSize.width / imageSize.height
let containerAspect = containerSize.width / containerSize.height

if imageAspect > containerAspect {
let visibleWidth = imageSize.height * containerAspect
return CGRect(
x: (imageSize.width - visibleWidth) / 2,
y: 0,
width: visibleWidth,
height: imageSize.height
)
} else {
let visibleHeight = imageSize.width / containerAspect
return CGRect(
x: 0,
y: (imageSize.height - visibleHeight) / 2,
width: imageSize.width,
height: visibleHeight
)
}
}
}


42 changes: 42 additions & 0 deletions Resell/Utils/Extensions/View + Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import SwiftUI
import UIKit

extension View {

Expand All @@ -18,6 +19,12 @@ extension View {
func endEditingOnTap() -> some View {
self.modifier(EndEditingOnTap())
}

/// Restores the system edge-swipe-to-pop while a custom back button hides
/// the navigation bar item that normally owns that gesture.
func enableSwipeBack() -> some View {
background(InteractivePopGestureEnabler())
}
}

struct EndEditingOnTap: ViewModifier {
Expand All @@ -29,3 +36,38 @@ struct EndEditingOnTap: ViewModifier {
}
}

private struct InteractivePopGestureEnabler: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> InteractivePopGestureController {
InteractivePopGestureController()
}

func updateUIViewController(_ uiViewController: InteractivePopGestureController, context: Context) {}
}

private final class InteractivePopGestureController: UIViewController, UIGestureRecognizerDelegate {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard let popGesture = navigationController?.interactivePopGestureRecognizer else { return }
popGesture.isEnabled = true
popGesture.delegate = self
}

override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if navigationController?.interactivePopGestureRecognizer?.delegate === self {
navigationController?.interactivePopGestureRecognizer?.delegate = nil
}
}

func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
(navigationController?.viewControllers.count ?? 0) > 1
}

func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
false
}
}

26 changes: 20 additions & 6 deletions Resell/Views/Components/BackButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,21 @@ struct BackButton: View {
// MARK: - Style

enum Style {
/// Default: SF Symbol `chevron.left` at 17pt medium, black tint.
/// Default: SF Symbol `chevron.left` at 17pt medium.
case systemChevron
/// Resizable SF Symbol `chevron.left` with an explicit content size.
case systemChevronResizable(width: CGFloat, height: CGFloat)
/// Asset image (e.g. `chevron.left`, `chevron.left.white`) with explicit size and tint.
/// Asset image (e.g. `chevron.left`, `chevron.left.white`) with explicit size.
/// `tint` on the style is used only when the view-level `tint` is nil.
case assetChevron(name: String, size: CGSize, tint: Color = Constants.Colors.black)
}

// MARK: - Properties

var style: Style = .systemChevron
/// Overrides the chevron color. Defaults to black for light chrome surfaces;
/// pass an adaptive color over photo heroes.
var tint: Color? = nil
/// Size of the tappable rect surrounding the chevron. Defaults to 44x44 (Apple HIG minimum).
/// Pass a smaller width when embedded in a custom HStack header that needs to preserve a narrower visual slot.
var hitTargetSize: CGSize = CGSize(width: 44, height: 44)
Expand Down Expand Up @@ -52,24 +56,34 @@ struct BackButton: View {
.buttonStyle(.plain)
}

private var resolvedTint: Color {
if let tint { return tint }
switch style {
case .assetChevron(_, _, let styleTint):
return styleTint
case .systemChevron, .systemChevronResizable:
return Constants.Colors.black
}
}

@ViewBuilder
private var label: some View {
switch style {
case .systemChevron:
Image(systemName: "chevron.left")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(Constants.Colors.black)
.foregroundStyle(resolvedTint)
case .systemChevronResizable(let width, let height):
Image(systemName: "chevron.left")
.resizable()
.scaledToFit()
.frame(width: width, height: height)
.foregroundStyle(Constants.Colors.black)
case .assetChevron(let name, let size, let tint):
.foregroundStyle(resolvedTint)
case .assetChevron(let name, let size, _):
Image(name)
.resizable()
.frame(width: size.width, height: size.height)
.foregroundStyle(tint)
.foregroundStyle(resolvedTint)
}
}
}
39 changes: 39 additions & 0 deletions Resell/Views/Components/GlassToolbarModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//
// GlassToolbarModifier.swift
// Resell
//
// Created by Andrew Gao on 9/3/26.
//

import SwiftUI

/// Liquid Glass background for the floating toolbar controls that sit over a
/// scrolling feed (search pill, filter button, notification button).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should put a modifier file into a diff direc maybe like "custom" or "modifier"

///
/// The near-clear fill and explicit `contentShape` are load-bearing: neither
/// `glassEffect` nor `Material` alone claims the whole pill for hit testing, so
/// taps near the edge of a control would fall through to the content behind it.
struct GlassToolbarModifier: ViewModifier {

var cornerRadius: CGFloat = 999
/// Opaque enough to keep foreground text legible over busy content.
var isOpaque: Bool = false

func body(content: Content) -> some View {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
if #available(iOS 26, *) {
content
.background { shape.fill(Color.white.opacity(isOpaque ? 0.55 : 0.001)) }
.contentShape(shape)
.glassEffect(.regular, in: shape)
} else {
content
.background { shape.fill(Color.white.opacity(0.001)) }
.contentShape(shape)
.background(
isOpaque ? AnyShapeStyle(.regularMaterial) : AnyShapeStyle(.ultraThinMaterial),
in: shape
)
}
}
}