Compare commits
5 Commits
f1028bd626
...
ace6303a62
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ace6303a62 | ||
|
|
34455378fc | ||
|
|
8cbd85dcce | ||
|
|
8e82c8ab2b | ||
|
|
6b17039918 |
File diff suppressed because it is too large
Load Diff
@ -19,6 +19,16 @@ enum Design {
|
||||
/// Set to true to show layout debug borders on views
|
||||
static let showDebugBorders = false
|
||||
|
||||
/// Set to true to show debug log statements
|
||||
static let showDebugLogs = false
|
||||
|
||||
/// Debug logger - only prints when showDebugLogs is true
|
||||
static func debugLog(_ message: String) {
|
||||
if showDebugLogs {
|
||||
print(message)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared Constants (from CasinoKit)
|
||||
|
||||
typealias Spacing = CasinoDesign.Spacing
|
||||
@ -71,6 +81,33 @@ enum Design {
|
||||
|
||||
// Table
|
||||
static let tableHeight: CGFloat = 280
|
||||
|
||||
// Result banner
|
||||
static let resultRowAmountWidth: CGFloat = 70
|
||||
static let resultRowResultWidth: CGFloat = 150
|
||||
|
||||
// Side bet zones
|
||||
static let sideBetLabelFontSize: CGFloat = 13
|
||||
static let sideBetPayoutFontSize: CGFloat = 11
|
||||
|
||||
// Side bet toast notifications
|
||||
static let sideBetToastTitleFontSize: CGFloat = 11
|
||||
static let sideBetToastResultFontSize: CGFloat = 12
|
||||
static let sideBetToastAmountFontSize: CGFloat = 14
|
||||
}
|
||||
|
||||
// MARK: - Blackjack-Specific Delays
|
||||
|
||||
enum Delay {
|
||||
/// Delay before playing game over sound
|
||||
static let gameOverSound: Double = 1.0
|
||||
}
|
||||
|
||||
// MARK: - Blackjack-Specific Animation
|
||||
|
||||
enum AnimationExtra {
|
||||
/// Bounce for side bet toast animations
|
||||
static let toastBounce: Double = 0.4
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -35,10 +35,8 @@ struct ActionButtonsView: View {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.animation(.spring(duration: Design.Animation.quick), value: state.currentPhase)
|
||||
.animation(.easeInOut(duration: Design.Animation.standard), value: state.currentBet > 0)
|
||||
}
|
||||
.frame(minHeight: containerHeight)
|
||||
.frame(height: containerHeight)
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
}
|
||||
|
||||
|
||||
@ -130,20 +130,22 @@ struct GameTableView: View {
|
||||
)
|
||||
.frame(maxWidth: maxContentWidth)
|
||||
|
||||
// Chip selector - only shown during betting phase
|
||||
if state.currentPhase == .betting {
|
||||
Spacer()
|
||||
.debugBorder(showDebugBorders, color: .yellow, label: "ChipSpacer")
|
||||
|
||||
// Chip selector - only shown during betting phase AND when result banner is NOT showing
|
||||
if state.currentPhase == .betting && !state.showResultBanner {
|
||||
ChipSelectorView(
|
||||
selectedChip: $selectedChip,
|
||||
balance: state.balance,
|
||||
currentBet: state.minBetForChipSelector, // Use min bet so chips stay enabled if any bet type can accept more
|
||||
currentBet: state.minBetForChipSelector,
|
||||
maxBet: state.settings.maxBet
|
||||
)
|
||||
.frame(maxWidth: maxContentWidth)
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
|
||||
.onAppear {
|
||||
Design.debugLog("🎰 Chip selector APPEARED (banner showing: \(state.showResultBanner))")
|
||||
}
|
||||
.onDisappear {
|
||||
Design.debugLog("🎰 Chip selector DISAPPEARED")
|
||||
}
|
||||
}
|
||||
|
||||
// Action buttons - minimal spacing during player turn
|
||||
@ -152,43 +154,78 @@ struct GameTableView: View {
|
||||
.padding(.bottom, Design.Spacing.small)
|
||||
.debugBorder(showDebugBorders, color: .blue, label: "ActionBtns")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(maxWidth: .infinity, alignment: .top)
|
||||
.zIndex(1)
|
||||
.onChange(of: state.currentPhase) { oldPhase, newPhase in
|
||||
Design.debugLog("🔄 Phase changed: \(oldPhase) → \(newPhase)")
|
||||
}
|
||||
|
||||
// Insurance popup overlay (covers entire screen)
|
||||
if state.currentPhase == .insurance {
|
||||
InsurancePopupView(
|
||||
betAmount: state.currentBet / 2,
|
||||
balance: state.balance,
|
||||
onTake: { Task { await state.takeInsurance() } },
|
||||
onDecline: { state.declineInsurance() }
|
||||
)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
Color.clear
|
||||
.overlay(alignment: .center) {
|
||||
InsurancePopupView(
|
||||
betAmount: state.currentBet / 2,
|
||||
balance: state.balance,
|
||||
onTake: { Task { await state.takeInsurance() } },
|
||||
onDecline: { state.declineInsurance() }
|
||||
)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(true)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
.zIndex(100)
|
||||
}
|
||||
|
||||
// Result banner overlay
|
||||
if state.showResultBanner, let result = state.lastRoundResult {
|
||||
ResultBannerView(
|
||||
result: result,
|
||||
currentBalance: state.balance,
|
||||
minBet: state.settings.minBet,
|
||||
onNewRound: { state.newRound() },
|
||||
onPlayAgain: { state.resetGame() }
|
||||
)
|
||||
Color.clear
|
||||
.overlay(alignment: .center) {
|
||||
ResultBannerView(
|
||||
result: result,
|
||||
currentBalance: state.balance,
|
||||
minBet: state.settings.minBet,
|
||||
onNewRound: { state.newRound() },
|
||||
onPlayAgain: { state.resetGame() }
|
||||
)
|
||||
.onAppear {
|
||||
Design.debugLog("🎯 RESULT BANNER APPEARED")
|
||||
}
|
||||
.onDisappear {
|
||||
Design.debugLog("❌ RESULT BANNER DISAPPEARED")
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(true)
|
||||
.zIndex(100)
|
||||
}
|
||||
|
||||
// Confetti for wins (matching Baccarat pattern)
|
||||
if state.showResultBanner && (state.lastRoundResult?.totalWinnings ?? 0) > 0 {
|
||||
ConfettiView()
|
||||
.zIndex(101)
|
||||
}
|
||||
|
||||
// Game over
|
||||
if state.isGameOver && !state.showResultBanner {
|
||||
GameOverView(
|
||||
roundsPlayed: state.roundsPlayed,
|
||||
onPlayAgain: { state.resetGame() }
|
||||
)
|
||||
Color.clear
|
||||
.overlay(alignment: .center) {
|
||||
GameOverView(
|
||||
roundsPlayed: state.roundsPlayed,
|
||||
onPlayAgain: { state.resetGame() }
|
||||
)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(true)
|
||||
.zIndex(100)
|
||||
}
|
||||
}
|
||||
.onChange(of: state.playerHands.count) { oldCount, newCount in
|
||||
Design.debugLog("👥 Player hands count: \(oldCount) → \(newCount)")
|
||||
}
|
||||
.onChange(of: state.balance) { oldBalance, newBalance in
|
||||
Design.debugLog("💰 Balance: \(oldBalance) → \(newBalance)")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -54,7 +54,6 @@ struct ResultBannerView: View {
|
||||
ZStack {
|
||||
// Full screen dark background
|
||||
Color.black.opacity(Design.Opacity.strong)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Content card
|
||||
VStack(spacing: Design.Spacing.xLarge) {
|
||||
@ -193,18 +192,18 @@ struct ResultBannerView: View {
|
||||
.shadow(color: mainResultColor.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusXLarge)
|
||||
.frame(maxWidth: CasinoDesign.Size.maxModalWidth)
|
||||
.padding(.horizontal, Design.Spacing.large) // Prevent clipping on sides
|
||||
.scaleEffect(showContent ? 1.0 : 0.8)
|
||||
.scaleEffect(showContent ? Design.Scale.normal : Design.Scale.slightShrink)
|
||||
.opacity(showContent ? 1.0 : 0)
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: 0.3)) {
|
||||
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce)) {
|
||||
showContent = true
|
||||
}
|
||||
|
||||
// Play game over sound if out of chips (after a delay so it doesn't overlap with result sound)
|
||||
if isGameOver {
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
try? await Task.sleep(for: .seconds(Design.Delay.gameOverSound))
|
||||
SoundManager.shared.play(.gameOver)
|
||||
}
|
||||
}
|
||||
@ -222,6 +221,8 @@ struct ResultRow: View {
|
||||
let result: HandResult
|
||||
var amount: Int? = nil
|
||||
|
||||
private var showDebugBorders: Bool { Design.showDebugBorders }
|
||||
|
||||
private var amountText: String? {
|
||||
guard let amount = amount else { return nil }
|
||||
if amount > 0 {
|
||||
@ -243,24 +244,31 @@ struct ResultRow: View {
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.font(.system(size: Design.BaseFontSize.medium))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
.debugBorder(showDebugBorders, color: .blue, label: "Label")
|
||||
|
||||
Spacer()
|
||||
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer")
|
||||
|
||||
// Show amount if provided
|
||||
if let amountText = amountText {
|
||||
Text(amountText)
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .semibold, design: .rounded))
|
||||
.font(.system(size: Design.BaseFontSize.medium, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(amountColor)
|
||||
.frame(width: 70, alignment: .trailing)
|
||||
.frame(width: Design.Size.resultRowAmountWidth, alignment: .trailing)
|
||||
.debugBorder(showDebugBorders, color: .green, label: "Amount")
|
||||
}
|
||||
|
||||
Text(result.displayText)
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .bold))
|
||||
.font(.system(size: Design.BaseFontSize.large, weight: .bold))
|
||||
.foregroundStyle(result.color)
|
||||
.frame(width: 100, alignment: .trailing)
|
||||
.frame(width: Design.Size.resultRowResultWidth, alignment: .trailing)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
|
||||
.debugBorder(showDebugBorders, color: .red, label: "Result")
|
||||
}
|
||||
.debugBorder(showDebugBorders, color: .white, label: "ResultRow")
|
||||
}
|
||||
}
|
||||
|
||||
@ -272,6 +280,8 @@ struct SideBetResultRow: View {
|
||||
let isWin: Bool
|
||||
let amount: Int
|
||||
|
||||
private var showDebugBorders: Bool { Design.showDebugBorders }
|
||||
|
||||
private var amountText: String {
|
||||
if amount > 0 {
|
||||
return "+$\(amount)"
|
||||
@ -295,23 +305,28 @@ struct SideBetResultRow: View {
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.font(.system(size: Design.BaseFontSize.medium))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
.debugBorder(showDebugBorders, color: .blue, label: "Label")
|
||||
|
||||
Spacer()
|
||||
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer")
|
||||
|
||||
Text(amountText)
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .semibold, design: .rounded))
|
||||
.font(.system(size: Design.BaseFontSize.medium, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(amountColor)
|
||||
.frame(width: 70, alignment: .trailing)
|
||||
.frame(width: Design.Size.resultRowAmountWidth, alignment: .trailing)
|
||||
.debugBorder(showDebugBorders, color: .green, label: "Amount")
|
||||
|
||||
Text(resultText)
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .bold))
|
||||
.font(.system(size: Design.BaseFontSize.large, weight: .bold))
|
||||
.foregroundStyle(resultColor)
|
||||
.frame(width: 100, alignment: .trailing)
|
||||
.frame(width: Design.Size.resultRowResultWidth, alignment: .trailing)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
|
||||
.debugBorder(showDebugBorders, color: .red, label: "Result")
|
||||
}
|
||||
.debugBorder(showDebugBorders, color: .white, label: "SideBetRow")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -31,9 +31,9 @@ struct PlayerHandsView: View {
|
||||
// Display hands in reverse order (right to left play order)
|
||||
// Visual order: Hand 3, Hand 2, Hand 1 (left to right)
|
||||
// Play order: Hand 1 played first (rightmost), then Hand 2, etc.
|
||||
ForEach(hands.indices.reversed(), id: \.self) { index in
|
||||
ForEach(Array(hands.enumerated()).reversed(), id: \.element.id) { index, hand in
|
||||
PlayerHandView(
|
||||
hand: hands[index],
|
||||
hand: hand,
|
||||
isActive: index == activeHandIndex && isPlayerTurn,
|
||||
showCardCount: showCardCount,
|
||||
// Hand numbers: rightmost (index 0) is Hand 1, played first
|
||||
@ -41,35 +41,39 @@ struct PlayerHandsView: View {
|
||||
cardWidth: cardWidth,
|
||||
cardSpacing: cardSpacing
|
||||
)
|
||||
.id(index)
|
||||
.id(hand.id)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.animation(.spring(duration: Design.Animation.springDuration), value: hands.count)
|
||||
.padding(.horizontal, Design.Spacing.xxLarge) // More padding for scrolling
|
||||
}
|
||||
.scrollClipDisabled()
|
||||
.scrollBounceBehavior(.always) // Always allow bouncing for better scroll feel
|
||||
.defaultScrollAnchor(.center) // Center the content by default
|
||||
.onChange(of: activeHandIndex) { _, newIndex in
|
||||
scrollToHand(proxy: proxy, index: newIndex)
|
||||
scrollToActiveHand(proxy: proxy)
|
||||
}
|
||||
.onChange(of: totalCardCount) { _, _ in
|
||||
// Scroll to active hand when cards are added (hit)
|
||||
scrollToHand(proxy: proxy, index: activeHandIndex)
|
||||
scrollToActiveHand(proxy: proxy)
|
||||
}
|
||||
.onChange(of: hands.count) { _, _ in
|
||||
// Scroll to active hand when split occurs
|
||||
scrollToHand(proxy: proxy, index: activeHandIndex)
|
||||
scrollToActiveHand(proxy: proxy)
|
||||
}
|
||||
.onAppear {
|
||||
scrollToHand(proxy: proxy, index: activeHandIndex)
|
||||
scrollToActiveHand(proxy: proxy)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func scrollToHand(proxy: ScrollViewProxy, index: Int) {
|
||||
private func scrollToActiveHand(proxy: ScrollViewProxy) {
|
||||
guard activeHandIndex < hands.count else { return }
|
||||
let activeHandId = hands[activeHandIndex].id
|
||||
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
|
||||
proxy.scrollTo(index, anchor: .center)
|
||||
proxy.scrollTo(activeHandId, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,9 +18,9 @@ struct SideBetToastView: View {
|
||||
|
||||
@State private var isShowing = false
|
||||
|
||||
@ScaledMetric(relativeTo: .caption) private var titleFontSize: CGFloat = 10
|
||||
@ScaledMetric(relativeTo: .caption2) private var resultFontSize: CGFloat = 11
|
||||
@ScaledMetric(relativeTo: .caption2) private var amountFontSize: CGFloat = 13
|
||||
@ScaledMetric(relativeTo: .caption) private var titleFontSize: CGFloat = Design.Size.sideBetToastTitleFontSize
|
||||
@ScaledMetric(relativeTo: .callout) private var resultFontSize: CGFloat = Design.Size.sideBetToastResultFontSize
|
||||
@ScaledMetric(relativeTo: .body) private var amountFontSize: CGFloat = Design.Size.sideBetToastAmountFontSize
|
||||
|
||||
private var backgroundColor: Color {
|
||||
isWin ? Color.green.opacity(Design.Opacity.heavy) : Color.red.opacity(Design.Opacity.heavy)
|
||||
@ -68,14 +68,14 @@ struct SideBetToastView: View {
|
||||
)
|
||||
)
|
||||
.shadow(color: borderColor.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusMedium)
|
||||
.scaleEffect(isShowing ? 1.0 : 0.5)
|
||||
.scaleEffect(isShowing ? Design.Scale.normal : Design.Scale.shrunk)
|
||||
.opacity(isShowing ? 1.0 : 0)
|
||||
.offset(x: isShowing ? 0 : (showOnLeft ? -Design.Spacing.toastSlide : Design.Spacing.toastSlide))
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(title): \(result)")
|
||||
.accessibilityValue(amountText)
|
||||
.onAppear {
|
||||
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: 0.4).delay(showOnLeft ? 0 : Design.Animation.staggerDelay1)) {
|
||||
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.AnimationExtra.toastBounce).delay(showOnLeft ? 0 : Design.Animation.staggerDelay1)) {
|
||||
isShowing = true
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,8 +16,8 @@ struct SideBetZoneView: View {
|
||||
let isAtMax: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
@ScaledMetric(relativeTo: .caption) private var labelFontSize: CGFloat = 11
|
||||
@ScaledMetric(relativeTo: .caption2) private var payoutFontSize: CGFloat = 9
|
||||
@ScaledMetric(relativeTo: .callout) private var labelFontSize: CGFloat = Design.Size.sideBetLabelFontSize
|
||||
@ScaledMetric(relativeTo: .caption) private var payoutFontSize: CGFloat = Design.Size.sideBetPayoutFontSize
|
||||
|
||||
private var backgroundColor: Color {
|
||||
switch betType {
|
||||
@ -49,7 +49,7 @@ struct SideBetZoneView: View {
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.strokeBorder(
|
||||
Color.white.opacity(Design.Opacity.hint),
|
||||
lineWidth: Design.LineWidth.thin
|
||||
lineWidth: Design.LineWidth.medium
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
17
Blackjack/BlackjackTests/BlackjackTests.swift
Normal file
17
Blackjack/BlackjackTests/BlackjackTests.swift
Normal file
@ -0,0 +1,17 @@
|
||||
//
|
||||
// BlackjackTests.swift
|
||||
// BlackjackTests
|
||||
//
|
||||
// Created by Matt Bruce on 12/17/25.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import Blackjack
|
||||
|
||||
struct BlackjackTests {
|
||||
|
||||
@Test func example() async throws {
|
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||
}
|
||||
|
||||
}
|
||||
41
Blackjack/BlackjackUITests/BlackjackUITests.swift
Normal file
41
Blackjack/BlackjackUITests/BlackjackUITests.swift
Normal file
@ -0,0 +1,41 @@
|
||||
//
|
||||
// BlackjackUITests.swift
|
||||
// BlackjackUITests
|
||||
//
|
||||
// Created by Matt Bruce on 12/17/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class BlackjackUITests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||
|
||||
// In UI tests it is usually best to stop immediately when a failure occurs.
|
||||
continueAfterFailure = false
|
||||
|
||||
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testExample() throws {
|
||||
// UI tests must launch the application that they test.
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunchPerformance() throws {
|
||||
// This measures how long it takes to launch your application.
|
||||
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||
XCUIApplication().launch()
|
||||
}
|
||||
}
|
||||
}
|
||||
33
Blackjack/BlackjackUITests/BlackjackUITestsLaunchTests.swift
Normal file
33
Blackjack/BlackjackUITests/BlackjackUITestsLaunchTests.swift
Normal file
@ -0,0 +1,33 @@
|
||||
//
|
||||
// BlackjackUITestsLaunchTests.swift
|
||||
// BlackjackUITests
|
||||
//
|
||||
// Created by Matt Bruce on 12/17/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class BlackjackUITestsLaunchTests: XCTestCase {
|
||||
|
||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunch() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Insert steps here to perform after app launch but before taking a screenshot,
|
||||
// such as logging into a test account or navigating somewhere in the app
|
||||
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = "Launch Screen"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user