diff --git a/Flipcash/Core/AppDelegate.swift b/Flipcash/Core/AppDelegate.swift index f96792248..b80104ff3 100644 --- a/Flipcash/Core/AppDelegate.swift +++ b/Flipcash/Core/AppDelegate.swift @@ -323,13 +323,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private extension AppDelegate { func setupAppearance() { + // No font: the bar's own large (34 pt bold) and inline (17 pt semibold) titles are the + // intended ones, and only the bar's defaults follow a text-size change live. let largeAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.appDisplayLarge, .foregroundColor: UIColor(.textMain), ] let titleAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.appTitle, .foregroundColor: UIColor(.textMain), ] @@ -342,13 +342,14 @@ private extension AppDelegate { buttonAppearance.normal.titleTextAttributes = buttonAttributes let bar = UINavigationBar.appearance() + let backArrow = Self.backIndicator() bar.largeTitleTextAttributes = largeAttributes bar.titleTextAttributes = titleAttributes if #available(iOS 26, *) { // iOS 26: use the system's default (Liquid Glass) bar background and - // only override the title fonts. A *transparent* background removes + // only override the title colour. A *transparent* background removes // the bar's glass material, so the buttons have no stable bar-glass // to composite against and the system repaints their glass during // the push transition — that's the flicker as screens push/settle. @@ -358,6 +359,7 @@ private extension AppDelegate { barAppearance.configureWithDefaultBackground() barAppearance.titleTextAttributes = titleAttributes barAppearance.largeTitleTextAttributes = largeAttributes + barAppearance.setBackIndicatorImage(backArrow, transitionMaskImage: backArrow) bar.standardAppearance = barAppearance bar.scrollEdgeAppearance = barAppearance @@ -371,6 +373,7 @@ private extension AppDelegate { barAppearance.titleTextAttributes = titleAttributes barAppearance.largeTitleTextAttributes = largeAttributes barAppearance.backButtonAppearance = buttonAppearance + barAppearance.setBackIndicatorImage(backArrow, transitionMaskImage: backArrow) bar.standardAppearance = barAppearance bar.scrollEdgeAppearance = barAppearance @@ -381,6 +384,16 @@ private extension AppDelegate { } } + + /// The navigation back glyph: Central's left arrow at the 22 pt the app's own back buttons use. + static func backIndicator() -> UIImage { + let size = CGSize(width: 22, height: 22) + let arrow = UIImage.icon(.arrowLeft) + return UIGraphicsImageRenderer(size: size).image { _ in + arrow.draw(in: CGRect(origin: .zero, size: size)) + } + .withRenderingMode(.alwaysTemplate) + } } // MARK: - UINavigationController - diff --git a/Flipcash/Core/FlipcashApp.swift b/Flipcash/Core/FlipcashApp.swift index 8afea57a6..34b02fa0a 100644 --- a/Flipcash/Core/FlipcashApp.swift +++ b/Flipcash/Core/FlipcashApp.swift @@ -23,6 +23,12 @@ struct FlipcashApp: App { #if DEBUG if MotionSandbox.isRequested { MotionSandbox() + } else if DialogSandbox.isRequested { + DialogSandbox() + } else if TraySandbox.isRequested { + TraySandbox() + } else if SwipeSandbox.isRequested { + SwipeShowcaseHost() } else if let benchmark = ScrollBenchmark.requested { benchmark } else { @@ -183,3 +189,51 @@ private extension View { modifier(ScenePhaseModifier(appDelegate: appDelegate)) } } + +#if DEBUG +/// Real `DialogItem`s through the real `.dialog(item:)` seam, with no sign-in (`--dialog-sandbox`, +/// `--dialog-sandbox-open `). Actions do nothing. +private struct DialogSandbox: View { + + static var isRequested: Bool { ProcessInfo.processInfo.arguments.contains("--dialog-sandbox") } + + @State private var dialogItem: DialogItem? + + private let trustedWebsites = TrustedWebsites(defaults: UserDefaults(suiteName: "dialog-sandbox")!) + + private var items: [(String, () -> DialogItem)] { + [ + ("Log out (alert, destructive)", { + .alert(title: "Are You Sure You Want To Log Out?", subtitle: "You can get into this account using your Access Key") { + DialogAction.destructive("Log Out") {} + DialogAction.cancel() + } + }), + ("Leaving Flipcash (info, checkbox)", { .leavingFlipcash(host: "example.com", trustedWebsites: trustedWebsites) {} }), + ("Error (tracked, default OK)", { .error(title: "Something Went Wrong", subtitle: "Please try again later") }), + ("Success (not dismissable)", { .success(title: "Report Sent", subtitle: "Thanks. We'll take a look") }), + ] + } + + var body: some View { + VStack(spacing: 12) { + ForEach(items.indices, id: \.self) { index in + Button(items[index].0) { dialogItem = items[index].1() } + .buttonStyle(.filled20Compact) + } + } + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.backgroundMain) + .dialog(item: $dialogItem) + .preferredColorScheme(.dark) + .task { + let arguments = ProcessInfo.processInfo.arguments + guard let i = arguments.firstIndex(of: "--dialog-sandbox-open"), i + 1 < arguments.count, + let index = Int(arguments[i + 1]), items.indices.contains(index) else { return } + try? await Task.sleep(for: .seconds(0.5)) + dialogItem = items[index].1() + } + } +} +#endif diff --git a/Flipcash/Core/Screens/Conversation/AttachMenu.swift b/Flipcash/Core/Screens/Conversation/AttachMenu.swift index 65fb669a8..e70786831 100644 --- a/Flipcash/Core/Screens/Conversation/AttachMenu.swift +++ b/Flipcash/Core/Screens/Conversation/AttachMenu.swift @@ -59,8 +59,8 @@ struct AttachMenu: View { } panel.animate { $0.toggle() } } label: { - Image(systemName: SystemSymbol.plus.rawValue) - .font(.default(size: 17, weight: .semibold)) + Image.icon(.plusLarge) + .iconSize(22) .foregroundStyle(Color.textMain) .frame(width: BarMetrics.accessorySize, height: BarMetrics.accessorySize) .background(AttachSurfaceLayout.plusFill, in: Circle()) @@ -149,7 +149,12 @@ struct AttachMenuRows: View { Button { onSelect(item) } label: { - Label(item.title, systemImage: item.systemImage) + Label { + Text(item.title) + } icon: { + Image.icon(item.icon) + .iconSize(22) + } .labelStyle(AttachMenuRowLabelStyle()) } .buttonStyle(.plain) @@ -173,7 +178,6 @@ private struct AttachMenuRowLabelStyle: LabelStyle { func makeBody(configuration: Configuration) -> some View { HStack(spacing: 14) { configuration.icon - .font(.default(size: 19, weight: .medium)) .frame(width: Self.iconDiameter, height: Self.iconDiameter) .background(Color.white.opacity(0.08), in: Circle()) configuration.title @@ -198,12 +202,12 @@ extension AttachMenuItem { } } - /// The row's SF Symbol. - var systemImage: String { + /// The row's glyph. + var icon: Icon { switch self { - case .cash: "banknote" - case .camera: "camera" - case .photos: "photo.on.rectangle" + case .cash: .banknote1 + case .camera: .camera1 + case .photos: .images3 } } } diff --git a/Flipcash/Core/Screens/Conversation/AttachSurface.swift b/Flipcash/Core/Screens/Conversation/AttachSurface.swift index be6e3802e..71dea1add 100644 --- a/Flipcash/Core/Screens/Conversation/AttachSurface.swift +++ b/Flipcash/Core/Screens/Conversation/AttachSurface.swift @@ -71,7 +71,7 @@ enum AttachSurfaceLayout { /// Matches the composer field's corner, so the menu reads as part of it. static let menuCornerRadius: CGFloat = BarMetrics.fieldCornerRadius /// `+`'s flat fill on the composer field, which the surface turns into as it reaches `+`. - static let plusFill = Color.white.opacity(0.10) + static let plusFill = Color.white.opacity(0.08) /// How far past `+`'s size the surface has fully turned from `+`'s fill into the panel's glass. static let plusBlendDistance: CGFloat = 40 /// Concentric with the screen's corners, which the card stands ``AttachOverlayLayout/cardInset`` in from. @@ -247,8 +247,8 @@ struct AttachSurface: View { /// `+`'s glyph where `+` stands, so the surface reads as `+` itself as it grows and collapses. private var plusGlyph: some View { - Image(systemName: SystemSymbol.plus.rawValue) - .font(.default(size: 17, weight: .semibold)) + Image.icon(.plusLarge) + .iconSize(22) .foregroundStyle(Color.textMain) .frame(width: plus.width, height: plus.height) .accessibilityHidden(true) diff --git a/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift b/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift index d13c994b7..a284f06c7 100644 --- a/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift +++ b/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift @@ -173,7 +173,7 @@ struct ChatCameraSheet: View { showsMoreControls.toggle() } } label: { - ChatCameraControlLabel(systemImage: showsMoreControls ? "xmark" : "ellipsis") + ChatCameraControlLabel(icon: showsMoreControls ? .crossMedium : .dotGrid1x3Horizontal) .overlay(alignment: .topTrailing) { if !showsMoreControls { Circle() @@ -212,7 +212,7 @@ struct ChatCameraSheet: View { return Button { camera.toggleFlash() } label: { - ChatCameraControlLabel(systemImage: isOn ? "bolt.fill" : "bolt.slash.fill") + ChatCameraControlLabel(icon: isOn ? .lightningBolt : .lightningBoltOff) } .buttonStyle(.plain) .accessibilityLabel("Flash") @@ -224,7 +224,7 @@ struct ChatCameraSheet: View { Button { camera.flip() } label: { - ChatCameraControlLabel(systemImage: "arrow.triangle.2.circlepath") + ChatCameraControlLabel(icon: .arrowsRepeat) } .buttonStyle(.plain) .accessibilityLabel(camera.position == .front ? "Use back camera" : "Use front camera") @@ -233,7 +233,7 @@ struct ChatCameraSheet: View { private var backButton: some View { Button(action: onCancel) { - ChatCameraControlLabel(systemImage: "chevron.left") + ChatCameraControlLabel(icon: .arrowLeft) } .buttonStyle(.plain) .accessibilityLabel("Back to attach menu") @@ -244,11 +244,11 @@ struct ChatCameraSheet: View { /// A round control over the viewfinder: a white glyph on dark-tinted glass. private struct ChatCameraControlLabel: View { - let systemImage: String + let icon: Icon var body: some View { - Image(systemName: systemImage) - .font(.default(size: 17, weight: .semibold)) + Image.icon(icon) + .iconSize(22) .foregroundStyle(Color.white) .frame(width: 44, height: 44) .overlayGlassBackground(in: Circle()) diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift b/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift index e07826e37..86d2e30d1 100644 --- a/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift +++ b/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift @@ -139,8 +139,8 @@ struct ChatPhotosCard: View { private var controls: some View { HStack { Button(action: onBack) { - Image(systemName: "chevron.left") - .font(.default(size: 17, weight: .semibold)) + Image.icon(.arrowLeft) + .iconSize(22) .foregroundStyle(Color.textMain) .frame(width: 44, height: 44) .contentShape(Circle()) diff --git a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift index 1601b20b8..2dae069b5 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift @@ -152,8 +152,8 @@ private struct ComposerChipView: View { Button(action: onRetry) { ZStack { Color.black.opacity(0.45) - Image(systemName: "arrow.clockwise") - .font(.default(size: 18, weight: .semibold)) + Image.icon(.arrowRotateClockwise) + .iconSize(22) .foregroundStyle(Color.white) } } @@ -163,8 +163,8 @@ private struct ComposerChipView: View { case .error: ZStack { Color.black.opacity(0.45) - Image(systemName: "exclamationmark.triangle.fill") - .font(.default(size: 18, weight: .semibold)) + Image.icon(.exclamationTriangle) + .iconSize(22) .foregroundStyle(Color.textError) } .accessibilityLabel("Photo can't be sent") @@ -238,8 +238,8 @@ struct ComposerLinkChip: View { } } } else { - Image(systemName: "link") - .font(.default(size: 20, weight: .semibold)) + Image.icon(.chainLink4) + .iconSize(24) .foregroundStyle(Color.white.opacity(0.6)) } } @@ -293,10 +293,12 @@ private struct ComposerChipRemoveButton: View { var body: some View { Button(action: action) { - Image(systemName: SystemSymbol.closeCircle.rawValue) - .font(.default(size: 18, weight: .semibold)) - .symbolRenderingMode(.palette) - .foregroundStyle(Color.white, Color.black.opacity(0.55)) + Image.icon(.circleXFilled) + .iconSize(22) + .foregroundStyle(Color.white) + // The glyph's ✕ is a hole in its disc, which vanishes over a light photo. A disc of + // the chat's ground under the glyph's middle half draws the ✕ instead. + .background { Circle().fill(Color.backgroundMain).scaleEffect(0.5) } .padding(3) .contentShape(Rectangle()) } diff --git a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift index 76231c354..432fe2308 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift @@ -69,18 +69,13 @@ struct ComposerReplyStrip: View { thumbnail - // A disc rather than a bare ✕, because the ground behind it is glass sampling the - // transcript: a hairline glyph's contrast changed with whatever message scrolled past. - // The fill gives it its own ground and takes that variable out. Button(action: onDismiss) { - Image(systemName: SystemSymbol.closeCircle.rawValue) - // `.system`, not the app face: an SF Symbol only takes its optical axes from a - // system font. - .font(.system(size: 22)) - // Palette, not monochrome. A monochrome fill knocks the ✕ out as a hole, which - // over glass fills with the transcript — the thing the disc is here to stop. - .symbolRenderingMode(.palette) - .foregroundStyle(Color.textMain, Color.textSecondary.opacity(0.35)) + Image.icon(.circleXFilled) + .iconSize(28) + .foregroundStyle(Color.textMain) + // The glyph's ✕ is a hole in its disc, which over glass shows the transcript. A + // disc of the chat's ground under the glyph's middle half draws the ✕ instead. + .background { Circle().fill(Color.backgroundMain).scaleEffect(0.5) } // Wider than the disc: the glyph is small enough to miss on its own. .frame(width: 34, height: 34) .contentShape(.rect) diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index ba4d490fb..7169b20a7 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -168,8 +168,8 @@ struct BarOverflowReporting: ViewModifier { } } -/// The jump-to-bottom button, centred over the bar's trailing control at the foot of the box the -/// screen stands on the bar's top edge. +/// The jump-to-bottom button, at the trailing foot of the box the screen stands on the bar's top +/// edge, so it rides the bar. /// /// It is hosted outside the bar, so the transcript's inset and the keyboard floor never see it and /// the bar's clip never cuts it. @@ -179,23 +179,20 @@ struct JumpToBottomOverlay: View { var body: some View { let isShown = jump.isShown - let isCompact = !model.isComposing // The attach panel takes every touch outside itself, the button's included. let takesTouches = !model.attachPanel.isOpen - // Centred on the trailing control's column, which is narrower than the button. - let inset = BarMetrics.trailingControlCentreInset(isCompact: isCompact) - - JumpToBottomButtonView.diameter / 2 + // The frame is the hit area, which reaches past the drawn circle; the insets place the circle. + let outset = JumpToBottomButtonView.hitOutset JumpToBottomButtonView(unreadCount: jump.unreadCount, action: jump.onTap) - .frame(width: JumpToBottomButtonView.diameter, height: JumpToBottomButtonView.diameter) + .frame(width: JumpToBottomButtonView.hitDiameter, height: JumpToBottomButtonView.hitDiameter) // Opacity only: a glass effect under a scale effect drew a shrunken copy of the screen. .opacity(isShown ? 1 : 0) // Hidden, the button also stops taking touches; opacity 0 alone would still be tappable. .allowsHitTesting(isShown && takesTouches) .accessibilityHidden(!isShown) .animation(ChatMotion.reflow.animation, value: isShown) - .padding(.trailing, inset) - .animation(ChatMotion.barInset.animation, value: isCompact) - .padding(.bottom, BarMetrics.jumpButtonGapAboveBar) + .padding(.trailing, BarMetrics.jumpButtonInset - outset) + .padding(.bottom, BarMetrics.jumpButtonGapAboveBar - outset) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) } } @@ -236,13 +233,16 @@ enum BarMetrics { /// The margin between the compact bar's controls and the screen's sides. static let compactInset: CGFloat = 32 /// The gap between the field and the `$` beside it, the same compact and composing. - nonisolated static let controlSpacing: CGFloat = 10 + nonisolated static let controlSpacing: CGFloat = 12 /// The gap between the jump-to-bottom button's bottom edge and the top edge of `$`: twice the /// field's gap to `$`. static let jumpButtonGap: CGFloat = controlSpacing * 2 /// How far above the bar's top edge the button's bottom edge stands, which is `$`'s top edge /// less the bar's own margin above its controls. static let jumpButtonGapAboveBar: CGFloat = jumpButtonGap - contentPadding + /// The margin between the jump-to-bottom button's circle and the screen's trailing side, resting + /// and typing alike: the composing bar's edge, which keeps the widest unread badge on screen. + static let jumpButtonInset: CGFloat = edgeInset /// The margin between the bar's controls and the screen's sides: ``compactInset`` while the bar /// rests compact, ``edgeInset`` while composing. @@ -250,8 +250,7 @@ enum BarMetrics { isCompact ? compactInset : edgeInset } - /// Distance from the screen's side to the centre of the bar's trailing control (Send Cash). The - /// jump-to-bottom button centres on this column. + /// Distance from the screen's side to the centre of the bar's trailing control (Send Cash). static func trailingControlCentreInset(isCompact: Bool) -> CGFloat { controlInset(isCompact: isCompact) + contentHeight / 2 } @@ -1157,6 +1156,8 @@ struct ConversationComposer: View { private static let cashJoinDelay: TimeInterval = 0.16 /// The text's and chips' inset from the field's leading edge. private static let leadingInset: CGFloat = 14 + /// The draft's type, shared by the field and the hidden measure of its one-line width. + private static let draftFont: Font = .default(size: 17, weight: .regular) /// The stacked text's insets from the field's leading and trailing edges. private static let stackedHorizontalInset: CGFloat = 14 /// The stacked text's inset from the field's top edge. @@ -1236,7 +1237,7 @@ struct ConversationComposer: View { var body: some View { let motion = AttachMotion(reduceMotion: reduceMotion) let textField = TextField(fieldPrompt, text: $composer.draft, selection: $composer.selection, axis: .vertical) - .font(.appTextMessage) + .font(Self.draftFont) .foregroundStyle(Color.textMain) .tint(.white) .lineLimit(1...7) @@ -1279,7 +1280,7 @@ struct ConversationComposer: View { .background(alignment: .leading) { // The draft's width on one line, to tell when the inline field would wrap. Text(composer.draft.isEmpty ? " " : composer.draft) - .font(.appTextMessage) + .font(Self.draftFont) .lineLimit(1) .fixedSize() .hidden() @@ -1376,14 +1377,13 @@ struct ConversationComposer: View { ZStack { if submitIsShown { Button(action: submit) { - Image(systemName: submitSymbol) - .font(.default(size: 16, weight: .bold)) + // Arrow and checkmark are the same button in two jobs, so the glyph swaps in + // place rather than the button popping out and a new one popping back. + Image.icon(submitIcon) + .iconSize(20) .foregroundStyle(Color.textAction) .frame(width: BarMetrics.accessorySize, height: BarMetrics.accessorySize) .background(Color.white, in: Circle()) - // Arrow and checkmark are the same button in two jobs, so the glyph swaps in - // place rather than the button popping out and a new one popping back. - .contentTransition(.symbolEffect(.replace)) } .buttonStyle(.plain) .accessibilityLabel(composer.isEditing ? "Save" : "Send") @@ -1419,8 +1419,8 @@ struct ConversationComposer: View { /// there's text to send otherwise. private var showsSubmit: Bool { composer.isEditing || composer.canSubmit } - private var submitSymbol: String { - composer.isEditing ? SystemSymbol.checkmark.rawValue : SystemSymbol.arrowUp.rawValue + private var submitIcon: Icon { + composer.isEditing ? .checkmark1 : .arrowUp } private func submit() { @@ -1520,8 +1520,8 @@ private struct CancelEditButton: View { var body: some View { Button(action: onCancel) { - Image(systemName: SystemSymbol.close.rawValue) - .font(.default(size: 17, weight: .semibold)) + Image.icon(.crossMedium) + .iconSize(22) .foregroundStyle(Color.textMain) .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) // The glyph is the only drawn content, so without a shape the taps that land on the diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index ada09f6f2..74972f641 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -781,7 +781,8 @@ struct ConversationScreen: View { Button { composer.endEditing() } label: { - Image(systemName: "chevron.backward") + Image.icon(.arrowLeft) + .iconSize(22) .foregroundStyle(Color.textMain) } .accessibilityLabel("Stop editing") @@ -1049,7 +1050,7 @@ struct ConversationScreen: View { ContactCardView(card: card) .ignoresSafeArea() } - .sheet(isPresented: $isShowingEncryptionInfo) { + .tray(isPresented: $isShowingEncryptionInfo) { E2eeLearnMoreSheet(kind: .dm, isPresented: $isShowingEncryptionInfo) } .fullScreenCover(item: $messageShare) { share in @@ -1961,15 +1962,15 @@ private struct ConversationTitleLabel: View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 4) { Text(title) - .font(.appBarButton) + .font(.default(size: 17, weight: .semibold)) .foregroundStyle(Color.textMain) .lineLimit(1) MuteBell(mute) } if let subtitle { Text(subtitle) - .font(.default(size: 13, weight: .medium)) - .foregroundStyle(Color.textMain.opacity(0.5)) + .font(.default(size: 13, weight: .regular)) + .foregroundStyle(Color.textMain.opacity(0.6)) .lineLimit(1) } } diff --git a/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift b/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift index 4e563b751..fcba9c2ff 100644 --- a/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift @@ -79,7 +79,8 @@ struct ReportFlowScreen: View { case .details: ToolbarItem(placement: .topBarLeading) { Button(action: goBackToReasons) { - Image(systemName: "chevron.backward") + Image.icon(.arrowLeft) + .iconSize(22) .foregroundStyle(Color.textMain) } .accessibilityIdentifier("report-nav-back") diff --git a/Flipcash/Core/Screens/Invite Links/GroupInviteScreen.swift b/Flipcash/Core/Screens/Invite Links/GroupInviteScreen.swift index ae172f108..cc43a46ae 100644 --- a/Flipcash/Core/Screens/Invite Links/GroupInviteScreen.swift +++ b/Flipcash/Core/Screens/Invite Links/GroupInviteScreen.swift @@ -151,7 +151,7 @@ struct GroupInviteScreen: View { overflowMenu } } - .sheet(isPresented: $isShowingE2ee) { + .tray(isPresented: $isShowingE2ee) { E2eeLearnMoreSheet(kind: .privateGroup, isPresented: $isShowingE2ee) } .fullScreenCover(isPresented: $isReporting) { diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift index dbf1aad46..cdfe86454 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountScreen.swift @@ -155,8 +155,8 @@ private struct BuyAmountScreenContent: View { monogramID: viewModel.paymentMint.base58, name: viewModel.paymentName ) - Image(systemName: "chevron.down") - .font(.system(size: 12, weight: .semibold)) + Image.icon(.chevronDownMedium) + .iconSize(16) .foregroundStyle(Color.textSecondary) } } diff --git a/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountScreen.swift b/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountScreen.swift index 8edb43e23..a222e0ed4 100644 --- a/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountScreen.swift @@ -126,8 +126,8 @@ private struct ConvertAmountScreenContent: View { monogramID: viewModel.destinationMint.base58, name: viewModel.destinationName ) - Image(systemName: "chevron.down") - .font(.system(size: 12, weight: .semibold)) + Image.icon(.chevronDownMedium) + .iconSize(16) .foregroundStyle(Color.textSecondary) } } diff --git a/Flipcash/Core/Screens/Main/Currency Convert/CurrencyPickerSheet.swift b/Flipcash/Core/Screens/Main/Currency Convert/CurrencyPickerSheet.swift index 30da3e6a3..6aabfa5f6 100644 --- a/Flipcash/Core/Screens/Main/Currency Convert/CurrencyPickerSheet.swift +++ b/Flipcash/Core/Screens/Main/Currency Convert/CurrencyPickerSheet.swift @@ -76,8 +76,8 @@ struct CurrencyPickerSheet: View { Spacer() if balance.stored.mint == selectedMint { - Image(systemName: "checkmark") - .font(.system(size: 14, weight: .semibold)) + Image.icon(.checkmark1) + .iconSize(18) .foregroundStyle(Color.textMain) } diff --git a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift index 08353f706..151acbc83 100644 --- a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift @@ -199,9 +199,11 @@ struct CurrencyCreationWizardScreen: View { .toolbar { ToolbarItem(placement: .topBarLeading) { Button(action: goBack) { - Image(systemName: "chevron.backward") + Image.icon(.arrowLeft) + .iconSize(22) .foregroundStyle(Color.textMain) } + .accessibilityLabel("Back") } if state.step.showsProgressBar { ToolbarItem(placement: .principal) { diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift index 392e553e6..23e5c4ba8 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift @@ -288,26 +288,20 @@ struct CurrencyInfoContentV2: View { // MARK: - Actions - /// An action-tile glyph: either an SF Symbol or a bundled template asset. - private enum TileIcon { - case system(String) - case asset(String) - } - @ViewBuilder private var actionTiles: some View { HStack(spacing: 8) { ForEach(CurrencyInfoActions.tiles(isOwned: isOwned, isUSDF: isUSDF), id: \.self) { tile in switch tile { case .send: - actionTile("Send", icon: .asset("IconTileSend"), action: onSend) + actionTile("Send", icon: .send, action: onSend) case .buy: - actionTile("Buy", icon: .asset("IconTileBuy"), action: onBuy) + actionTile("Buy", icon: .arrowDown, action: onBuy) case .buyIn: - actionTile("Buy In", icon: .asset("IconTileBuy"), action: onBuy) + actionTile("Buy In", icon: .arrowDown, action: onBuy) case .transactions: - actionTile("Transactions", icon: .system("clock.arrow.circlepath"), action: onShowTransactionHistory) + actionTile("Transactions", icon: .history, action: onShowTransactionHistory) case .more: - actionTile("More", icon: .system("ellipsis"), action: onMore) + actionTile("More", icon: .dotGrid1x3Horizontal, action: onMore) } } } @@ -315,14 +309,14 @@ struct CurrencyInfoContentV2: View { private func actionTile( _ title: String, - icon: TileIcon, + icon: Icon, isEnabled: Bool = true, action: @escaping () -> Void ) -> some View { Button(action: action) { - VStack(spacing: 8) { - tileIcon(icon) - .frame(height: 24) + VStack(spacing: 12) { + Image.icon(icon) + .iconSize(28) // 12pt medium, as drawn: "Transactions" fits its tile with room either side. Text(title) .font(.default(size: 12, weight: .medium)) @@ -339,21 +333,6 @@ struct CurrencyInfoContentV2: View { .opacity(isEnabled ? 1 : 0.4) } - @ViewBuilder private func tileIcon(_ icon: TileIcon) -> some View { - switch icon { - case .system(let name): - Image(systemName: name) - .font(.system(size: 24, weight: .regular)) - case .asset(let name): - // Figma exports these glyphs at 24×24 (template-rendered, so they - // tint with the tile's foreground). - Image(name) - .resizable() - .scaledToFit() - .frame(width: 24, height: 24) - } - } - // MARK: - Copy private var currencyDescription: String { diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoErrorView.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoErrorView.swift index c703240f7..4b7c0006e 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoErrorView.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoErrorView.swift @@ -16,9 +16,9 @@ struct CurrencyInfoErrorView: View { VStack(spacing: 20) { Spacer() - Image("IconExclamationCircle") - .resizable() - .frame(width: 100, height: 100) + Image.icon(.exclamationCircle) + .iconSize(100) + .foregroundStyle(Color.white) .padding(24) // Status Text diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index b1ff752e6..365f359cb 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -348,8 +348,10 @@ private struct CurrencyInfoScreenContent: View { Analytics.buttonTapped(name: .shareTokenInfo) isShowingShare = true } label: { - Image(systemName: "square.and.arrow.up") + Image.icon(.shareOs) + .iconSize(22) } + .accessibilityLabel("Share") } } } @@ -445,8 +447,8 @@ private struct CurrencyInfoScreenContent: View { Button { overlayClose?() } label: { - Image(systemName: "xmark") - .font(.system(size: 17, weight: .semibold)) + Image.icon(.crossMedium) + .iconSize(22) .foregroundStyle(Color.textMain) .frame(width: Self.overlayBarHeight, height: Self.overlayBarHeight) // Hit-test the whole target, not just the glyph. @@ -467,8 +469,8 @@ private struct CurrencyInfoScreenContent: View { Analytics.buttonTapped(name: .shareTokenInfo) isShowingShare = true } label: { - Image(systemName: "square.and.arrow.up") - .font(.system(size: 17, weight: .semibold)) + Image.icon(.shareOs) + .iconSize(22) .foregroundStyle(Color.textMain) .frame(width: Self.overlayBarHeight, height: Self.overlayBarHeight) .contentShape(Circle()) diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoSocialLinksSection.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoSocialLinksSection.swift index d8872a5d3..c16802a95 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoSocialLinksSection.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoSocialLinksSection.swift @@ -12,13 +12,15 @@ import FlipcashCore /// A link chip drawn to the design: a 17pt glyph and the app small text label in a 6pt-radius tile. /// Local to this section because the shared `IconButtonStyle` sets a bold 16pt label and an 18pt glyph. private struct LinkChipStyle: ButtonStyle { - let icon: Asset + let icon: Image - init(_ icon: Asset) { self.icon = icon } + init(_ icon: Asset) { self.icon = Image.asset(icon) } + + init(_ icon: Icon) { self.icon = Image.icon(icon) } func makeBody(configuration: Configuration) -> some View { HStack(spacing: 10) { - Image.asset(icon) + icon .resizable() .aspectRatio(contentMode: .fit) .frame(width: 17, height: 17) @@ -45,7 +47,7 @@ struct CurrencyInfoSocialLinksSection: View { switch socialLink { case .website(let url): Link("Website", destination: url) - .buttonStyle(LinkChipStyle(.globus)) + .buttonStyle(LinkChipStyle(Icon.globe)) case .x(let handle): if let url = URL.socialProfile(host: "x.com", handle: handle) { Link("@\(handle)", destination: url) diff --git a/Flipcash/Core/Screens/Main/Currency Selection/CurrencySelectionScreen.swift b/Flipcash/Core/Screens/Main/Currency Selection/CurrencySelectionScreen.swift index be30c1669..ff87daee3 100644 --- a/Flipcash/Core/Screens/Main/Currency Selection/CurrencySelectionScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Selection/CurrencySelectionScreen.swift @@ -29,34 +29,47 @@ struct CurrencySelectionScreen: View { Group { if viewModel.isSearching { if !viewModel.searchingCurrencies.isEmpty { - Section(header: ListHeader("Results")) { - ForEach(viewModel.searchingCurrencies) { description in - CurrencyRow(description: description, viewModel: viewModel, allowDelete: false) - } + ListSectionTitle("Results") + ForEach(viewModel.searchingCurrencies) { description in + CurrencyRow( + description: description, + viewModel: viewModel, + allowDelete: false, + isLast: description.id == viewModel.searchingCurrencies.last?.id + ) } } } else { if !viewModel.availableRecentCurrencies.isEmpty { - Section(header: ListHeader("Recent Regions")) { - ForEach(viewModel.availableRecentCurrencies) { description in - CurrencyRow( - description: description, - viewModel: viewModel, - allowDelete: !viewModel.isCurrencyActive(description.currency) - ) - } + ListSectionTitle("Recent Regions") + ForEach(viewModel.availableRecentCurrencies) { description in + CurrencyRow( + description: description, + viewModel: viewModel, + allowDelete: !viewModel.isCurrencyActive(description.currency), + isLast: description.id == viewModel.availableRecentCurrencies.last?.id + ) } } - Section(header: ListHeader("Other Regions")) { - ForEach(viewModel.availableCurrencies) { description in - CurrencyRow(description: description, viewModel: viewModel, allowDelete: false) - } + ListSectionTitle("Other Regions") + ForEach(viewModel.availableCurrencies) { description in + CurrencyRow( + description: description, + viewModel: viewModel, + allowDelete: false, + isLast: description.id == viewModel.availableCurrencies.last?.id + ) } } } - .listRowSeparatorTint(Color.rowSeparator) + // Rows v2: each row draws its own divider and section titles are rows, so the + // list adds no insets, separators or header chrome of its own. + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.backgroundMain) } - .listStyle(.grouped) + .listStyle(.plain) + .environment(\.defaultMinListRowHeight, 0) .scrollContentBackground(.hidden) .overlay { if viewModel.isSearching && viewModel.searchingCurrencies.isEmpty { @@ -84,20 +97,22 @@ private struct CurrencyRow: View { let description: CurrencyDescription let viewModel: CurrencySelectionViewModel let allowDelete: Bool + let isLast: Bool var body: some View { if allowDelete { - CurrencyRowContent(description: description, viewModel: viewModel) + CurrencyRowContent(description: description, viewModel: viewModel, isLast: isLast) .swipeActions { Button { withAnimation { viewModel.removeRecent(description.currency) } } label: { - Image.asset(.delete) + Image.icon(.trashCan) } .tint(.bannerError) + .accessibilityLabel("Remove") } } else { - CurrencyRowContent(description: description, viewModel: viewModel) + CurrencyRowContent(description: description, viewModel: viewModel, isLast: isLast) } } } @@ -106,30 +121,26 @@ private struct CurrencyRowContent: View { @Environment(\.dismiss) private var dismiss let description: CurrencyDescription let viewModel: CurrencySelectionViewModel + let isLast: Bool var body: some View { - Button { + ListRow(description.localizedName, showsDivider: !isLast) { viewModel.select(currency: description.currency) dismiss() - } label: { - HStack(spacing: 15) { - Flag(style: description.currency.flagStyle) - VStack(alignment: .leading, spacing: 5) { - Text(description.localizedName) - .foregroundStyle(.textMain) - .font(.appTextMedium) - .multilineTextAlignment(.leading) - .layoutPriority(10) - } - Spacer() - RadioButton(isSelected: viewModel.isCurrencyActive(description.currency)) + } leading: { + Flag(style: description.currency.flagStyle, size: .none) + .frame(width: ListRowMetrics.flagSize, height: ListRowMetrics.flagSize) + .clipShape(Circle()) + .overlay { Circle().strokeBorder(Color.rowSeparator, lineWidth: 1) } + } trailing: { + if viewModel.isCurrencyActive(description.currency) { + Image.icon(.checkmark1) + .iconSize(22) + .foregroundStyle(Color.textMain) } - .padding(.vertical, 12) - .background(Color.backgroundMain) } - .opacity(viewModel.opacity(for: description.currency)) + .font(.appTextMedium) + .accessibilityAddTraits(viewModel.isCurrencyActive(description.currency) ? .isSelected : []) .disabled(viewModel.isSelectionDisabled(for: description.currency)) - .listRowBackground(Color.backgroundMain) - .buttonStyle(.plain) } } diff --git a/Flipcash/Core/Screens/Main/Home/HomeTab.swift b/Flipcash/Core/Screens/Main/Home/HomeTab.swift index fee80787c..263c2dbe7 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTab.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTab.swift @@ -4,14 +4,14 @@ // import SwiftUI +import FlipcashUI /// The tabs of the v2 tab-bar UI, in display order (left → right). Chat sits /// second, beside the scanner that feeds it. Declaration order is the bar's /// order, not the launch tab; see ``initial``. Matches Android's /// `NavBarButton.tabs`. /// -/// Icons are the Figma tab-bar glyphs (`Nav*` template imagesets, from the same -/// vectors as Android's `ic_nav_*`), tinted white at the call site. +/// Icons are Central glyphs (``icon(isSelected:)``), tinted at the call site. enum HomeTab: Int, CaseIterable, Identifiable, Hashable { case scan case chat @@ -24,24 +24,13 @@ enum HomeTab: Int, CaseIterable, Identifiable, Hashable { /// account, and when onboarding finishes creating a new one. static let initial: HomeTab = .chat - /// The asset-catalog name of the tab's template glyph. Selected tabs use a - /// filled glyph and the rest an outline, per the tab bar spec. - /// - /// The scanner reads tip cards, so it carries the tip card itself rather - /// than a viewfinder, and You takes the people-circle the scanner's slot - /// left free — the glyph it shows when there is no profile photo to draw in - /// its place (node 10000:111297). - /// - /// That fallback's filled weight comes from Material Icons' `account_circle` - /// rather than from the design file, which draws the You tab only as an - /// outline. It is the same glyph solid, and Android's people circle takes its - /// selected weight from the same place, so the two bars fill alike. - func iconName(isSelected: Bool) -> String { + /// The tab's Central glyph: filled when selected, outlined otherwise, per the tab bar spec. + func icon(isSelected: Bool) -> Icon { switch self { - case .scan: return isSelected ? "NavTipCardSelected" : "NavTipCard" - case .chat: return isSelected ? "NavChatSelected" : "NavChat" - case .wallet: return isSelected ? "NavWalletSelected" : "NavWallet" - case .tipCard: return isSelected ? "NavPeopleSelected" : "NavPeople" + case .scan: return isSelected ? .kikCodeFilled : .kikCode + case .chat: return isSelected ? .chatBubblesFilled : .chatBubbles + case .wallet: return isSelected ? .wallet5Filled : .wallet5 + case .tipCard: return isSelected ? .peopleCircleFilled : .peopleCircle } } diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabBar.swift b/Flipcash/Core/Screens/Main/Home/HomeTabBar.swift index 8c6508d82..8e5c8e49b 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabBar.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabBar.swift @@ -114,7 +114,7 @@ struct HomeTabBar: View { .scaleEffect(Self.iconSize / ProfileTabIcon.slotSize) .frame(width: Self.iconSize, height: Self.iconSize) } else { - Image(tab.iconName(isSelected: tab == selection)) + Image.icon(tab.icon(isSelected: tab == selection)) .renderingMode(.template) .resizable() .scaledToFit() diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index 06fbe6e19..721e31c44 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -41,27 +41,11 @@ struct HomeTabView: View { _selection = State(initialValue: HomeTab.initial) _hasOpenedChat = State(initialValue: HomeTab.initial == .chat) - // Color the tab glyphs white in both states, as iOS 27 already does; the - // outline-vs-filled glyph carries the selection. The iOS 26 tab bar - // ignores UITabBar.unselectedItemTintColor, so drive it through a full - // item appearance instead. A transparent background - // keeps the system's Liquid Glass. No-op on the legacy pill path, which - // draws its own buttons rather than a `UITabBar`. + // The iOS 26 tab bar ignores UITabBar.unselectedItemTintColor, so the + // colours go through a full item appearance instead. No-op on the legacy + // pill path, which draws its own buttons rather than a `UITabBar`. if #available(iOS 26, *) { - let items = UITabBarItemAppearance() - items.normal.iconColor = UIColor(Color.textMain) - items.selected.iconColor = UIColor(Color.textMain) - // Labels per node 10642:1325: 10pt Avenir Demi, colored like the glyph above. - let titleFont = UIFont.default(size: 10, weight: .semibold) - items.normal.titleTextAttributes = [.font: titleFont, .foregroundColor: UIColor(Color.textMain)] - items.selected.titleTextAttributes = [.font: titleFont, .foregroundColor: UIColor(Color.textMain)] - - let appearance = UITabBarAppearance() - appearance.configureWithTransparentBackground() - appearance.stackedLayoutAppearance = items - appearance.inlineLayoutAppearance = items - appearance.compactInlineLayoutAppearance = items - + let appearance = Self.tabBarAppearance() UITabBar.appearance().standardAppearance = appearance UITabBar.appearance().scrollEdgeAppearance = appearance } @@ -111,6 +95,28 @@ struct HomeTabView: View { sessionContainer.conversationController.unreadChatListCount } + /// The bar's item colours: selected tabs in full, the rest dimmed to + /// ``TabBarItemStyle/unselectedOpacity``, over a transparent background that keeps + /// the system's Liquid Glass. + static func tabBarAppearance() -> UITabBarAppearance { + let selectedColor = UIColor(Color.textMain) + let normalColor = selectedColor.withAlphaComponent(TabBarItemStyle.unselectedOpacity) + let items = UITabBarItemAppearance() + items.normal.iconColor = normalColor + items.selected.iconColor = selectedColor + // Labels per node 10642:1325, colored like the glyph above. + let titleFont = UIFont.default(size: 10, weight: .semibold) + items.normal.titleTextAttributes = [.font: titleFont, .foregroundColor: normalColor] + items.selected.titleTextAttributes = [.font: titleFont, .foregroundColor: selectedColor] + + let appearance = UITabBarAppearance() + appearance.configureWithTransparentBackground() + appearance.stackedLayoutAppearance = items + appearance.inlineLayoutAppearance = items + appearance.compactInlineLayoutAppearance = items + return appearance + } + var body: some View { tabs .background(Color.backgroundMain) @@ -274,7 +280,7 @@ struct HomeTabView: View { // pill's white indicator on a translucent capsule. .tabViewSearchActivation(.searchTabSelection) .tint(Color.textMain) - .toolbarBackground(Color.backgroundMain.opacity(0.5), for: .tabBar) + .toolbarBackground(Color.backgroundMain.opacity(TabBarItemStyle.backgroundOpacity), for: .tabBar) // Gives each item both of its glyphs. The tab bar knows which item the // Liquid Glass pill is over mid-drag and renders that one selected, so // handing it the pair is what makes the icons fill under the finger @@ -323,22 +329,25 @@ struct HomeTabView: View { /// This has to agree with what the probe writes. SwiftUI rewrites the item's /// image from this label whenever it rebuilds the bar, so a label that /// disagreed would take turns with the probe and flicker between the two. - private func tabLabel(for tab: HomeTab) -> some View { - Label { - Text(tab.title) - } icon: { + @ViewBuilder private func tabLabel(for tab: HomeTab) -> some View { + if TabBarItemStyle.showsNativeTitles { + Label { + Text(tab.title) + } icon: { + tabIcon(for: tab) + } + } else { tabIcon(for: tab) } } - @ViewBuilder private func tabIcon(for tab: HomeTab) -> some View { - if tab == .tipCard, let photo = profileItemImages?.normal { - Image(uiImage: photo).renderingMode(.original) - } else if tab == .chat, let badged = chatItemImages?.normal { - Image(uiImage: badged).renderingMode(.original) - } else { - Image(tab.iconName(isSelected: false)) - } + private func tabIcon(for tab: HomeTab) -> some View { + let images = TabBarItemStyle.itemImages( + for: tab, + profile: tab == .tipCard ? profileItemImages : nil, + badged: tab == .chat ? chatItemImages : nil + ) + return Image(uiImage: images.normal).renderingMode(.original) } private static let pillBottomMargin: CGFloat = 8 @@ -591,28 +600,22 @@ private struct TabBarSelectedIcons: UIViewControllerRepresentable { installLongPress(on: bar) for (item, tab) in zip(items, tabs) { - if tab == .tipCard, let profileImages { - // Already rendered at the dimmed and full opacities the bar - // would otherwise get by tinting a template glyph. - item.image = profileImages.normal - item.selectedImage = profileImages.selected - continue - } + // The drawn-in count is invisible to VoiceOver, so the item + // carries it the way the system badge did. if tab == .chat { - // The drawn-in count is invisible to VoiceOver, so the item - // carries it the way the system badge did. item.accessibilityValue = chatBadgeCount > 0 ? "\(chatBadgeCount) unread" : nil - if let badged = TabBarBadgedIcon.itemImages(for: .chat, count: chatBadgeCount) { - item.image = badged.normal - item.selectedImage = badged.selected - continue - } } - - item.image = UIImage(named: tab.iconName(isSelected: false))? - .withRenderingMode(.alwaysTemplate) - item.selectedImage = UIImage(named: tab.iconName(isSelected: true))? - .withRenderingMode(.alwaysTemplate) + let images = TabBarItemStyle.itemImages( + for: tab, + profile: tab == .tipCard ? profileImages : nil, + badged: tab == .chat ? TabBarBadgedIcon.itemImages(for: .chat, count: chatBadgeCount) : nil + ) + item.image = images.normal + item.selectedImage = images.selected + // A title drawn into the image leaves the item none of its own, + // so VoiceOver still needs the name. + item.title = TabBarItemStyle.showsNativeTitles ? tab.title : nil + item.accessibilityLabel = tab.title } } diff --git a/Flipcash/Core/Screens/Main/Home/RecentActivitySection.swift b/Flipcash/Core/Screens/Main/Home/RecentActivitySection.swift index ef4033d14..8f43bb282 100644 --- a/Flipcash/Core/Screens/Main/Home/RecentActivitySection.swift +++ b/Flipcash/Core/Screens/Main/Home/RecentActivitySection.swift @@ -25,8 +25,8 @@ struct RecentActivitySection: View { Text("Recent") .font(.appTextLarge) .foregroundStyle(Color.textMain) - Image(systemName: "chevron.right") - .font(.system(size: 15, weight: .semibold)) + Image.icon(.chevronRightMedium) + .iconSize(20) .foregroundStyle(Color.textSecondary) Spacer() } diff --git a/Flipcash/Core/Screens/Main/Home/TabBarBadgedIcon.swift b/Flipcash/Core/Screens/Main/Home/TabBarBadgedIcon.swift index 427e7bd2e..3d150ab95 100644 --- a/Flipcash/Core/Screens/Main/Home/TabBarBadgedIcon.swift +++ b/Flipcash/Core/Screens/Main/Home/TabBarBadgedIcon.swift @@ -15,7 +15,7 @@ import FlipcashUI enum TabBarBadgedIcon { /// The slot every tab glyph is drawn into. - private static let glyphSize: CGFloat = 32 + private static let glyphSize: CGFloat = TabBarItemStyle.glyphSize /// How far the bubble reaches past the glyph's top-right corner, matching /// the legacy pill's badge (node 8966:1846). @@ -42,14 +42,16 @@ enum TabBarBadgedIcon { private static var memoized: (tab: HomeTab, count: Int, images: TabBarProfilePhoto.ItemImages)? /// The glyph colored as the bar would tint it for the state, since an - /// original-rendered image is not tinted, with the bubble at full strength. + /// original-rendered image is not tinted (dimmed when unselected), with the + /// bubble at full strength. private static func icon(for tab: HomeTab, count: Int, isSelected: Bool) -> some View { - Image(tab.iconName(isSelected: isSelected)) + Image.icon(tab.icon(isSelected: isSelected)) .renderingMode(.template) .resizable() .scaledToFit() .foregroundStyle(Color.textMain) .frame(width: glyphSize, height: glyphSize) + .opacity(isSelected ? 1 : TabBarItemStyle.unselectedOpacity) .overlay(alignment: .topTrailing) { Bubble(size: .regular, count: count, color: .unreadIndicator) .fixedSize() diff --git a/Flipcash/Core/Screens/Main/Home/TabBarItemStyle.swift b/Flipcash/Core/Screens/Main/Home/TabBarItemStyle.swift new file mode 100644 index 000000000..22f67eb9e --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/TabBarItemStyle.swift @@ -0,0 +1,96 @@ +// +// TabBarItemStyle.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI + +/// How the iOS 26 tab bar draws its items. +/// +/// The system bar draws template glyphs and its own titles at full strength in +/// both states, so each item image carries its own colour, dimming and title. +/// The bar is left only the selection lens. +@MainActor +enum TabBarItemStyle { + + /// The point size every tab glyph is drawn at, badged or not. + static let glyphSize: CGFloat = 28 + /// The square every tab image is centred in above its title: the profile photo's slot, which + /// also leaves room for a badge's overhang, so every title sits on one line. + static let slotSize: CGFloat = ProfileTabIcon.slotSize + /// Moves the glyph and its title down (positive) or up within the bar. + static let verticalOffset: CGFloat = 0 + /// The opacity of the glyph and title of the tabs that aren't selected. + static let unselectedOpacity: Double = 0.5 + /// The drawn title's point size. + static let titleSize: CGFloat = 10 + /// The space between the glyph and the drawn title. + static let titleGap: CGFloat = 2 + /// The opacity of the bar's tint over its Liquid Glass. + static let backgroundOpacity: Double = 0.5 + + /// Whether the bar shows the system's titles, which can't be dimmed, rather than the titles drawn into each image. + static let showsNativeTitles = false + + /// Both images the bar shows for `tab`. A profile photo or badged glyph + /// arrives already dimmed for its state, so only its title is dimmed here. + static func itemImages( + for tab: HomeTab, + profile: TabBarProfilePhoto.ItemImages?, + badged: TabBarProfilePhoto.ItemImages? + ) -> TabBarProfilePhoto.ItemImages { + if let prerendered = profile ?? badged { + return TabBarProfilePhoto.ItemImages( + normal: compose(prerendered.normal, glyphAlpha: 1, title: tab.title, isSelected: false), + selected: compose(prerendered.selected, glyphAlpha: 1, title: tab.title, isSelected: true) + ) + } + return TabBarProfilePhoto.ItemImages( + normal: compose(glyph(for: tab, isSelected: false), glyphAlpha: unselectedOpacity, title: tab.title, isSelected: false), + selected: compose(glyph(for: tab, isSelected: true), glyphAlpha: 1, title: tab.title, isSelected: true) + ) + } + + /// The tab's glyph at ``glyphSize`` in the text colour. + private static func glyph(for tab: HomeTab, isSelected: Bool) -> UIImage { + let glyph = UIImage.icon(tab.icon(isSelected: isSelected)).withTintColor(UIColor(Color.textMain)) + let size = CGSize(width: glyphSize, height: glyphSize) + return UIGraphicsImageRenderer(size: size).image { _ in + glyph.draw(in: CGRect(origin: .zero, size: size)) + } + } + + /// Centres the glyph in ``slotSize`` over its drawn title and pads the canvas so the bar, + /// which centres the image, lands the pair ``verticalOffset`` from centre. + private static func compose(_ glyph: UIImage, glyphAlpha: Double, title: String, isSelected: Bool) -> UIImage { + let textColor = UIColor(Color.textMain).withAlphaComponent(isSelected ? 1 : unselectedOpacity) + let text = NSAttributedString(string: title, attributes: [.font: titleFont, .foregroundColor: textColor]) + + // The glyph's alignment rect leaves out a badge's overhang, so the slot centres on the glyph. + let insets = glyph.alignmentRectInsets + let glyphWidth = glyph.size.width - insets.left - insets.right + let glyphHeight = glyph.size.height - insets.top - insets.bottom + let textSize = CGSize(width: ceil(text.size().width), height: ceil(text.size().height)) + + let width = max(slotSize, textSize.width) + let contentHeight = slotSize + titleGap + textSize.height + let canvas = CGSize(width: width, height: contentHeight + 2 * abs(verticalOffset)) + let top = abs(verticalOffset) + verticalOffset + + let glyphOrigin = CGPoint( + x: (width - glyphWidth) / 2 - insets.left, + y: top + (slotSize - glyphHeight) / 2 - insets.top + ) + return UIGraphicsImageRenderer(size: canvas).image { _ in + glyph.draw(at: glyphOrigin, blendMode: .normal, alpha: glyphAlpha) + text.draw(at: CGPoint( + x: (width - textSize.width) / 2, + y: top + slotSize + titleGap + )) + } + .withRenderingMode(.alwaysOriginal) + } + + private static let titleFont = UIFont.default(size: titleSize, weight: .semibold) +} diff --git a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift index 4d00fd2b6..93fe664d7 100644 --- a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift @@ -246,11 +246,11 @@ struct TransactionDetailsScreen: View { Group { if didCopyID { - Image.system(.circleCheck) - .renderingMode(.template) + Image.icon(.circleCheck) + .iconSize(16) } else { - Image.asset(.squareBehindSquare) - .renderingMode(.template) + Image.icon(.squareBehindSquare1) + .iconSize(16) } } .frame(width: 16, height: 16) diff --git a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift index f4ba78837..1a13e32f9 100644 --- a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift @@ -327,8 +327,8 @@ private struct WalletScreenContent: View { private func openCardChrome(mint: PublicKey) -> some View { HStack { Button { closeCard() } label: { - Image(systemName: "chevron.left") - .font(.system(size: 17, weight: .semibold)) + Image.icon(.arrowLeft) + .iconSize(22) .foregroundStyle(Color.textMain) .frame(width: 44, height: 44) // Without this the button only takes taps on the glyph @@ -337,19 +337,21 @@ private struct WalletScreenContent: View { .modifier(WalletCircleGlass()) } .buttonStyle(.plain) + .accessibilityLabel("Back") .accessibilityIdentifier("wallet-open-card-back") Spacer(minLength: 0) if mint != .usdf { ShareLink(item: URL(string: "https://app.flipcash.com/token/\(mint.base58)")!) { - Image(systemName: "square.and.arrow.up") - .font(.system(size: 17, weight: .semibold)) + Image.icon(.shareOs) + .iconSize(22) .foregroundStyle(Color.textMain) .frame(width: 44, height: 44) .contentShape(Circle()) .modifier(WalletCircleGlass()) } + .accessibilityLabel("Share") .simultaneousGesture(TapGesture().onEnded { Analytics.buttonTapped(name: .shareTokenInfo) }) @@ -576,43 +578,34 @@ private struct WalletScreenContent: View { private var walletTiles: some View { VStack(spacing: 12) { HStack(spacing: 12) { - walletTile(icon: .asset(.coinsAdd), title: "Create Your Own Currency", identifier: "wallet-tile-create-currency") { + walletTile(icon: .coinsAdd, title: "Create Your Own Currency", identifier: "wallet-tile-create-currency") { router.push(.currencyCreationSummary) } - walletTile(icon: .symbol("globe"), title: "Currency Leaderboard", identifier: "wallet-tile-discover-currencies") { + walletTile(icon: .globe, title: "Currency Leaderboard", identifier: "wallet-tile-discover-currencies") { router.push(.discoverCurrencies) } } HStack(spacing: 12) { - // `.deposit`/`.withdraw` are the app's canonical design-system - // glyphs for these actions — a matched arrow-to-baseline pair, - // not SF Symbols. - walletTile(icon: .asset(.deposit), title: "Add Dollars", identifier: "wallet-tile-add-money") { + walletTile(icon: .arrowWallDown, title: "Add Dollars", identifier: "wallet-tile-add-money") { router.presentAddMoney(.general, source: .balance) } // A nil mint opens the currency picker (all balances). - walletTile(icon: .asset(.withdraw), title: "Withdraw Dollars", identifier: "wallet-tile-withdraw-money") { + walletTile(icon: .arrowUpWall, title: "Withdraw Dollars", identifier: "wallet-tile-withdraw-money") { router.push(.withdrawCurrency(nil)) } } } } - /// A wallet tile's leading glyph: an SF Symbol, or a bundled design-system - /// asset for marks SF Symbols don't cover (the two-coin "create currency" one). - private enum TileGlyph { - case symbol(String) - case asset(Asset) - } - /// A tile-style entry point: icon top-leading, label pinned bottom-leading, /// inside a translucent rounded card. They tile two-up per row. /// /// `identifier` is what tests target, so they do not depend on the label. - private func walletTile(icon: TileGlyph, title: String, identifier: String, action: @escaping () -> Void) -> some View { + private func walletTile(icon: Icon, title: String, identifier: String, action: @escaping () -> Void) -> some View { Button(action: action) { VStack(alignment: .leading, spacing: 0) { - tileGlyph(icon) + Image.icon(icon) + .iconSize(24) .foregroundStyle(Color.textMain) Spacer(minLength: 24) Text(title) @@ -631,22 +624,6 @@ private struct WalletScreenContent: View { .accessibilityIdentifier(identifier) } - /// Renders a `TileGlyph` at a consistent ~24pt: SF Symbols sized by font, - /// asset glyphs scaled to fit and tinted via the enclosing `foregroundStyle`. - @ViewBuilder - private func tileGlyph(_ glyph: TileGlyph) -> some View { - switch glyph { - case .symbol(let name): - Image(systemName: name) - .font(.system(size: 22, weight: .regular)) - case .asset(let asset): - Image.asset(asset) - .resizable() - .scaledToFit() - .frame(width: 24, height: 24) - } - } - private var recentActivitySection: some View { // The header opens the full cross-token history here; currency info // passes its own per-token destination. @@ -661,10 +638,8 @@ private struct WalletScreenContent: View { router.presentAddMoney(.general, source: .balance) } label: { HStack(alignment: .top, spacing: 12) { - Image(systemName: "plus.circle") - .resizable() - .scaledToFit() - .frame(width: 24, height: 24) + Image.icon(.circlePlus) + .iconSize(24) .foregroundStyle(Color.textSecondary) VStack(alignment: .leading, spacing: 4) { diff --git a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift index 76c929f5e..052d5ce2b 100644 --- a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift +++ b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift @@ -263,7 +263,8 @@ private struct AccessoryView: View { var body: some View { switch accessory { case .chevron: - Image.system(.chevronRight) + Image.icon(.chevronRightMedium) + .iconSize(20) .foregroundStyle(Color.textSecondary) case .check(let isSelected): RadioButton(isSelected: isSelected) diff --git a/Flipcash/Core/Screens/Main/Tips/ArchivedChatsScreen.swift b/Flipcash/Core/Screens/Main/Tips/ArchivedChatsScreen.swift index 4ae769dc9..cb7d5fead 100644 --- a/Flipcash/Core/Screens/Main/Tips/ArchivedChatsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/ArchivedChatsScreen.swift @@ -34,9 +34,7 @@ struct ArchivedChatsScreen: View { Button { conversationController.unarchive(conversation.id) } label: { - // Swipe actions fill symbols by default; the shared design uses the outline. - Image(systemName: "tray.and.arrow.up") - .environment(\.symbolVariants, .none) + Image.icon(.arrowInbox) } .tint(.backgroundRow) .accessibilityLabel("Unarchive chat") diff --git a/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift b/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift index 1c2e3db9f..f44627937 100644 --- a/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift +++ b/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift @@ -16,10 +16,12 @@ struct ArchivedRow: View { var body: some View { Button(action: onTap) { - HStack(spacing: 12) { + HStack(spacing: RecipientRowMetrics.avatarGap) { // Secondary throughout: a way into a folder, quieter than the chats below it. - Image(systemName: "archivebox") - .frame(minWidth: 45) + // The glyph's slot is a chat avatar's width, so "Archived" starts where chat titles do. + Image.icon(.archive) + .iconSize(24) + .frame(width: RecipientRowMetrics.avatarSize) Text("Archived") .font(.appTextMedium) Spacer() @@ -27,16 +29,14 @@ struct ArchivedRow: View { Text("\(count)") .font(.appTextSmall) } - Image(systemName: "chevron.right") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 9, height: 12) + ListRowChevron() } .foregroundStyle(Color.textSecondary) .padding(.vertical, 6) .contentShape(Rectangle()) } .buttonStyle(.plain) + .listRowInsets(EdgeInsets(top: 14, leading: 20, bottom: 14, trailing: 20)) .accessibilityLabel(count > 0 ? "Archived, \(count) unread" : "Archived") .accessibilityIdentifier("chat-archived-row") } diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/ShareToChatsSheet.swift b/Flipcash/Core/Screens/Main/Tips/New Group/ShareToChatsSheet.swift index f57556491..4d2a2dfaa 100644 --- a/Flipcash/Core/Screens/Main/Tips/New Group/ShareToChatsSheet.swift +++ b/Flipcash/Core/Screens/Main/Tips/New Group/ShareToChatsSheet.swift @@ -404,7 +404,7 @@ struct ShareToChatsSheet: View { if card != nil { ShareTile(title: "Show Profile Card") { // The Scan tab's outline glyph: the card is what the scanner reads. - Image(HomeTab.scan.iconName(isSelected: false)) + Image.icon(HomeTab.scan.icon(isSelected: false)) .renderingMode(.template) .resizable() .scaledToFit() @@ -418,7 +418,7 @@ struct ShareToChatsSheet: View { if Self.showsGroupCardTile(for: subject) { ShareTile(title: "Show Group Card") { - Image(HomeTab.scan.iconName(isSelected: false)) + Image.icon(HomeTab.scan.icon(isSelected: false)) .renderingMode(.template) .resizable() .scaledToFit() diff --git a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift index 1c6514530..b4e3cff2f 100644 --- a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift +++ b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift @@ -24,7 +24,7 @@ struct RecipientRowScaffold: View { let avatarID: String let title: String /// The preview line. Attributed so a caller can style part of it — the - /// empty-chat placeholder is italic while a real preview is not. + /// empty-chat placeholder is italic, and a chat's preview sets its author in bold. let subtitle: AttributedString? let imageData: Data? var blurhash: String? = nil @@ -68,15 +68,15 @@ struct RecipientRowBody: View { @ViewBuilder let trailing: Trailing var body: some View { - HStack(spacing: 12) { + HStack(spacing: RecipientRowMetrics.avatarGap) { ContactAvatarView( id: avatarID, displayName: title, imageData: imageData, blurhash: blurhash, - size: 44 + size: RecipientRowMetrics.avatarSize ) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 3) { HStack(alignment: .top, spacing: 6) { // Beside the title, as in the transcript's title bar: the mute belongs to the // chat, and the title names it. A long title truncates; the bell never does. @@ -95,9 +95,10 @@ struct RecipientRowBody: View { } if let subtitle { Text(subtitle) - .font(.appTextSmall) + .font(.default(size: 14, weight: .regular)) .foregroundStyle(Color.textSecondary) - .lineLimit(1) + .tracking(0.16) + .lineLimit(2) .contentTransition(.opacity) .transition(.opacity) } @@ -114,6 +115,14 @@ struct RecipientRowBody: View { } } +/// The avatar column every conversation row shares; a row above the chats lines up with it. +nonisolated enum RecipientRowMetrics { + /// The avatar's diameter. + static let avatarSize: CGFloat = 52 + /// The space between the avatar and the text. + static let avatarGap: CGFloat = 12 +} + private extension View { /// Row chrome shared by every conversation row: list insets, clear /// background, separator tint, and single-element button accessibility. @@ -121,6 +130,10 @@ private extension View { self .buttonStyle(.plain) .listRowInsets(EdgeInsets(top: 14, leading: 20, bottom: 14, trailing: 20)) + // Starts where the title does, past the avatar and its gap. + .alignmentGuide(.listRowSeparatorLeading) { _ in + RecipientRowMetrics.avatarSize + RecipientRowMetrics.avatarGap + } .listRowBackground(Color.clear) .listRowSeparatorTint(.rowSeparator) .accessibilityElement(children: .ignore) @@ -129,62 +142,30 @@ private extension View { } } -/// A row's trailing accessory: a relative timestamp (or "Unknown Contact" -/// pill), the unread count, or a disclosure chevron. +/// A row's trailing accessory: the last activity's time (or an "Unknown Contact" pill), followed +/// by a dot while the chat has unread messages. struct RecipientRowAccessory: View { let timestamp: Date let isUnknown: Bool let hasUnread: Bool - /// The count the unread pill carries. Nil while unread draws a bare dot: the count can't - /// always be known, and a guessed number would be wrong. - var unreadCount: Int? = nil var body: some View { - let count = hasUnread ? unreadCount.flatMap { $0 > 0 ? $0 : nil } : nil - // The design sets the count pill 4pt off the timestamp; the dot and chevron keep 6. - HStack(spacing: count == nil ? 6 : 4) { + HStack(spacing: 6) { if isUnknown { Text("Unknown Contact") .fixedSize(horizontal: true, vertical: false) .pill() } else { - Text(timestamp.formattedRelatively(useTimeForToday: true)) - .font(.appTextSmall) + Text(timestamp.formattedChatListTime()) + .font(.default(size: 14, weight: .regular)) .foregroundStyle(hasUnread ? Color.unreadIndicator : Color.textSecondary) } - if let count { - UnreadCountPill(count: count) - } else if hasUnread { + if hasUnread { Circle() .fill(Color.unreadIndicator) .frame(width: 9, height: 9) - } else { - Image(systemName: "chevron.right") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 9, height: 12) - .foregroundStyle(Color.textSecondary) } } } } - -/// A chat's unread count beside its timestamp (node 10329:8245): dark digits on the unread -/// blue, 18pt tall, a circle for one digit that widens into a capsule for more. -private struct UnreadCountPill: View { - - let count: Int - - var body: some View { - Text(UnreadCountLabel.text(for: count)) - .font(.appTextHeading) - .tracking(-0.72) - .foregroundStyle(Color.backgroundMain) - .lineLimit(1) - .fixedSize() - .padding(.horizontal, 4) - .frame(minWidth: 18, minHeight: 18) - .background(Color.unreadIndicator, in: .capsule) - } -} diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index 13b3e3c5e..6f88cc4c5 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -10,6 +10,9 @@ import FlipcashUI /// The Chats tab: the list of tip conversations — tips sent and received. struct TipConversationsScreen: View { + /// Set once the list scrolls the large title away, handing it to the bar's inline title. + @State private var titleCollapsed = false + @Environment(ConversationController.self) private var conversationController @Environment(SessionContainer.self) private var sessionContainer @Environment(AppRouter.self) private var router @@ -46,6 +49,12 @@ struct TipConversationsScreen: View { /// A selected filter keeps the chips in view: they explain the list. private var showsChips: Bool { chipsRevealed || filter != .all } + /// Whether the large title is drawn as content. Only iOS 26's glass bar lets it show through; + /// iOS 18's opaque bar would cover it, so there the bar keeps its own inline title. + private var drawsContentTitle: Bool { + if #available(iOS 26, *) { true } else { false } + } + var body: some View { let projection = self.projection let conversations = rows(projection) @@ -114,6 +123,13 @@ struct TipConversationsScreen: View { let insets = proxy.safeAreaInsets return max(insets.leading, insets.trailing) > insets.bottom } action: { isTabBarOnSide = $0 } + // Collapses the large title 24 pt past where the list rests, which is past the chips + // while they're hidden. + .onScrollGeometryChange(for: Bool.self) { geometry in + geometry.contentOffset.y + geometry.contentInsets.top > (showsChips ? 0 : chipsHeight) + 24 + } action: { _, collapsed in + withAnimation(.smooth(duration: 0.25)) { titleCollapsed = collapsed } + } .overlay { // Over the List rather than a row in it, so it centres in the whole // screen and the List keeps the pull that reveals the chips. @@ -126,18 +142,44 @@ struct TipConversationsScreen: View { } } } - .sheet(item: $muteTarget) { target in + // The large title is content, not a toolbar item, so a push slides it away with the page + // instead of morphing it through the bar's glass. + .overlay(alignment: .topLeading) { + if drawsContentTitle { + Text("Chat") + .font(.system(size: 34, weight: .bold)) + .foregroundStyle(Color.textMain) + .fixedSize() + .padding(.leading, 16) + .offset(y: -53) + .opacity(titleCollapsed ? 0 : 1) + .allowsHitTesting(false) + .accessibilityAddTraits(.isHeader) + } + } + .tray(item: $muteTarget) { target in MuteChatSheet(conversationID: target.id, isPresented: isPickingMuteDuration) } .navigationTitle("Chat") .toolbarTitleDisplayMode(.inline) .toolbar { + if drawsContentTitle { + ToolbarItem(placement: .principal) { + Text("Chat") + .font(.headline) + .foregroundStyle(Color.textMain) + .opacity(titleCollapsed ? 1 : 0) + .accessibilityHidden(!titleCollapsed) + } + } if showsSearchButton { ToolbarItem(placement: .topBarTrailing) { Button { router.push(.chatSearch) } label: { - Image(systemName: "magnifyingglass") + Image.icon(.magnifyingGlass) + .iconSize(24) + .foregroundStyle(Color.textMain) } .accessibilityLabel("Search") .accessibilityIdentifier("chat-search-button") @@ -215,13 +257,13 @@ struct TipConversationsScreen: View { /// settings screen rather than toggling the mute itself. private func muteAction(for conversation: Conversation) -> some View { let isMuted = conversation.isMuted(at: .now) - let symbol: SystemSymbol = isMuted ? .bell : .bellSlash + let icon: Icon = isMuted ? .bell2 : .bell2Off return Button { muteTarget = MuteTarget(id: conversation.id) } label: { // A swipe action draws its label white whatever the style says, so the amber has to be // baked into the image. - Image(uiImage: UIImage(systemName: symbol.rawValue)! + Image(uiImage: UIImage.icon(icon) .withTintColor(UIColor(Color.warning), renderingMode: .alwaysOriginal)) } // The mute chip's amber-on-amber, not the delete red: nothing is lost by it. @@ -236,9 +278,7 @@ struct TipConversationsScreen: View { conversationController.archive(conversation.id) toasts.show(.archivedChat { conversationController.unarchive(conversation.id) }) } label: { - // Swipe actions fill symbols by default; the shared design uses the outline. - Image(systemName: "archivebox") - .environment(\.symbolVariants, .none) + Image.icon(.archive) } .tint(.backgroundRow) // A swipe action is also a VoiceOver custom action, next to the mute one. @@ -281,8 +321,8 @@ private struct NewChatButton: View { } label: { // Bar items get their glass from the system on iOS 26, so this // carries no button style of its own. - Image.system(.plus) - .font(.appTextLarge) + Image.icon(.plusLarge) + .iconSize(24) .foregroundStyle(Color.textMain) } .accessibilityLabel("New chat") @@ -361,7 +401,6 @@ struct TipConversationRow: View { let unreadMessageCount = hasUnreadMessages ? conversationController.unreadCount(for: conversation) : nil let subtitle = joinRequests.subtitle(preview: self.subtitle, hasUnreadMessages: hasUnreadMessages) let hasUnread = hasUnreadMessages || joinRequests.count > 0 - let unreadCount = joinRequests.pillCount(hasUnreadMessages: hasUnreadMessages, unreadMessageCount: unreadMessageCount) // A pending request moves the row up, so the time shown is the one it sorts by. let timestamp = conversationController.chatListJoinRequests(for: conversation.id) .sortTime(after: conversation.lastActivity) @@ -392,8 +431,7 @@ struct TipConversationRow: View { RecipientRowAccessory( timestamp: timestamp, isUnknown: false, - hasUnread: hasUnread, - unreadCount: unreadCount + hasUnread: hasUnread ) } .accessibilityIdentifier(chatTypeIdentifier) @@ -418,21 +456,28 @@ struct TipConversationRow: View { return id } - /// The preview line: the last message, or an italic "Nothing yet" for a chat with no message - /// at all. A group is created empty, and a blank line there reads as a row still loading. - /// Only a chat that never had a message gets the placeholder — a last message the preview - /// declines to quote (a tombstone, an empty body) is not nothing, so it leaves the line off. + /// The preview line: the last message, with its "Name:" set apart in bold, or an italic + /// "Nothing yet" for a chat with no message at all. A group is created empty, and a blank line + /// there reads as a row still loading. Only a chat that never had a message gets the + /// placeholder — a last message the preview declines to quote (a tombstone, an empty body) is + /// not nothing, so it leaves the line off. private var subtitle: AttributedString? { + let knownAuthors = sessionContainer.knownAuthors.snapshot if let preview = conversationController.lastMessagePreview( for: conversation, - knownAuthors: sessionContainer.knownAuthors.snapshot, + knownAuthors: knownAuthors, viewerAmount: session.displayCurrency.amount, requestTitle: { id in sessionContainer.paymentRequests.request(id).flatMap(sessionContainer.paymentRequests.text)?.title }, currencyName: { session.balance(for: $0)?.name } ) { - return AttributedString(preview) + var line = AttributedString(preview) + if let author = previewAuthor(in: preview, knownAuthors: knownAuthors), + let end = line.characters.index(line.startIndex, offsetBy: author.count, limitedBy: line.endIndex) { + line[line.startIndex.. String? { + guard !conversationController.isCounterpartTyping(in: conversation.id), + let message = conversation.lastMessage + else { return nil } + switch message.content { + case .text, .media: + break + case .cash, .deleted, .encrypted, .widget, .system: + return nil + } + let names: [String?] + if message.isFromSelf(conversationController.selfUserID) { + names = ["You"] + } else if conversation.type == .group, let senderID = message.senderID { + names = [ + conversation.members.first { $0.userID == senderID }?.displayName, + knownAuthors.membersByUserID[senderID]?.displayName, + ] + } else { + return nil + } + return names + .compactMap { $0 } + .filter { !$0.isEmpty } + .map { "\($0):" } + .first { preview.hasPrefix("\($0) ") } + } + /// Names the row's chat type, so UI tests can tell a tip DM from a group — the label is only a name. private var chatTypeIdentifier: String { switch conversation.type { @@ -463,7 +539,7 @@ struct TipConversationRow: View { .joined(separator: ", ") } - /// The count when the row shows one, else the bare state the dot stands for. + /// The count once it's known, else the bare state the dot stands for. private func unreadLabel(count: Int?) -> String { switch count { case .some(1): "1 unread message" diff --git a/Flipcash/Core/Screens/Main/You/TipCardExport.swift b/Flipcash/Core/Screens/Main/You/TipCardExport.swift index 63ea018c2..22f28c6dc 100644 --- a/Flipcash/Core/Screens/Main/You/TipCardExport.swift +++ b/Flipcash/Core/Screens/Main/You/TipCardExport.swift @@ -21,8 +21,8 @@ enum TipCardExport { /// Codes are drawn light-on-dark, so an export with no ground behind it is /// invisible the moment it lands on a light surface. Both formats paint the - /// app's own background (`background.colorset`) behind the marks. - private static let backgroundHex = "#19191A" + /// app's own background (`backgroundMain`) behind the marks. + private static var backgroundHex: String { Color.backgroundMain.hexString } /// Renders `codeData` as `format` into a file named for `name`, or returns /// `nil` if rendering or writing it out failed. diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index c61b5a3ea..cc48d106b 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -165,7 +165,7 @@ struct YouScreen: View { } private var shareButton: some View { - ProfileActionCircle(image: Image.asset(.shareOS)) { + ProfileActionCircle(image: Image.icon(.shareOs)) { isSharingToChats = true } .accessibilityLabel("Share") @@ -187,7 +187,8 @@ struct YouScreen: View { Button { router.push(.settings) } label: { - Image(systemName: "gearshape") + Image.icon(.settingsGear2) + .iconSize(24) .notificationDot(showsUpdateDot) } .accessibilityLabel("Settings") diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 932274e1e..694e6e0aa 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -11,14 +11,15 @@ import FlipcashCore import FlipcashUI import FlipcashStore -/// A group chat's own profile (node 10913:358): the shared profile header with the group's cover, -/// picture and description, who is chatting, what it takes to join and to chat, and one pinned -/// button that opens the chat. +/// A group chat's own profile: a public group's cover-colored page with its requirements and +/// pulse, or a private group's shared profile header with its members, and one pinned button that +/// opens the chat. /// /// Reached by tapping the chat's head card or its navigation title, the way a DM's title opens the /// counterpart's profile. The actions a member has over the group sit in the ⋯ menu. Opened from /// the chat, there is no pinned button, since the chat is one back away, and Leave Chat ends the -/// scroll; otherwise Leave Chat sits under the pinned button. +/// scroll; otherwise a private group's Leave Chat sits under the pinned button and a public +/// group's ends the scroll. struct ChatProfileScreen: View { let conversationID: ConversationID @@ -38,6 +39,9 @@ struct ChatProfileScreen: View { @State private var isRequesting = false @State private var hostName: String? @State private var dialogItem: DialogItem? + @State private var isListingChatters = false + /// The chatter picked in the chatter list, whose profile opens once the list's tray has gone. + @State private var pickedChatter: UserID? @State private var chatters: [SampledChatter] = [] @State private var senders: [UserID] = [] @State private var mintMetadata: [PublicKey: StoredMintMetadata] = [:] @@ -134,6 +138,121 @@ struct ChatProfileScreen: View { // MARK: - Body - var body: some View { + screen + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + overflowMenu + } + } + .dialog(item: $dialogItem) + .invitePeople( + conversationID, + canInvite: conversation?.canInvite ?? false, + isPresented: $isInviting, + onManageInvites: { router.push(.inviteLinks(conversationID)) } + ) { chatID in + router.openSharedChat(chatID) + } + .tray(isPresented: $isPickingMuteDuration) { + MuteChatSheet(conversationID: conversationID, isPresented: $isPickingMuteDuration) + } + .tray(isPresented: $isShowingE2ee) { + E2eeLearnMoreSheet(kind: isPrivate ? .privateGroup : .group, isPresented: $isShowingE2ee) + } + .tray(isPresented: $isListingChatters, onDismiss: openPickedChatter) { + ChattersTray(chatters: chatters) { userID in + pickedChatter = userID + isListingChatters = false + } + } + .fullScreenCover(isPresented: $isReporting) { + NavigationStack { + ReportFlowScreen(target: .chat(conversationID)) + } + } + .task { + // A favorite group can be one the user isn't in, which the feed doesn't hold. + let conversation = await conversationController.hydratedConversation(withID: conversationID) + await sessionContainer.profileAvatars.load(.chat(conversationID), picture: conversation?.picture) + await conversationController.fillCoverPicture(for: conversationID) + } + // No push says a request was decided, so every visit asks. Keyed on membership so an + // admission that lands while the profile is open stops the asking. + .task(id: isRequester) { + guard isRequester else { return } + if let fresh = await conversationController.refreshRequestedGroup(conversationID) { + sessionContainer.lobbyRequests.reconcile(with: fresh) + } + } + .task(id: requestedAt != nil ? conversation?.creator : nil) { + guard requestedAt != nil, let creator = conversation?.creator else { return } + hostName = try? await sessionContainer.flipClient.fetchProfile( + userID: creator, + owner: session.ownerKeyPair + ).displayName + } + // Waits for the conversation to land before asking: a private group's sample is denied. + .task(id: conversation.map { $0.isPrivate && $0.creator == conversationController.selfUserID }) { + guard let conversation, conversation.isPrivate, conversation.creator == conversationController.selfUserID else { return } + await sessionContainer.hostedLobbies.refresh(conversationID) + } + // A private group's members read its roster in place of the denied sample. + .task(id: isPrivate && isMember) { + guard isPrivate, isMember else { return } + await sessionContainer.groupRosters.refresh(conversationID) + } + // GetRoster is off server-side and SampleChatters refuses private groups, so the grid falls + // back to the people this device has seen post. + .task(id: isPrivate && isMember) { + guard isPrivate, isMember else { return } + senders = conversationController.senderIDs(in: conversationID) + let creator = conversation?.creator.map { [$0] } ?? [] + await sessionContainer.knownAuthors.resolve(creator + [conversationController.selfUserID] + senders) + } + .task(id: conversation.map { $0.isPrivate } ) { + guard let conversation, !conversation.isPrivate else { return } + await loadChatters() + } + // Name the requirements in the token they ask for. The mint may be one the user holds + // nothing of, so the local store can miss and the fetch is what fills it. + .task(id: namedMints) { + var metadata: [PublicKey: StoredMintMetadata] = [:] + for mint in namedMints { + if let stored = session.storedMintMetadata(for: mint) { + metadata[mint] = stored + } else if let fetched = try? await session.fetchMintMetadata(mint: mint) { + metadata[mint] = fetched + } + } + mintMetadata = metadata + } + .task(id: namedMints) { + mintNamesTimedOut = false + try? await Task.sleep(for: .seconds(1.5)) + guard !Task.isCancelled else { return } + mintNamesTimedOut = true + } + } + + /// A public group gets its cover-colored page; a private group keeps the shared profile layout. + /// A group not yet loaded gets a plain page, so neither layout draws and then swaps. + @ViewBuilder + private var screen: some View { + if let conversation { + if conversation.isPrivate { + privateScreen + } else { + publicScreen + } + } else { + Background(color: .backgroundMain) { + Color.clear + } + } + } + + /// The shared profile layout, drawn for a private group. + private var privateScreen: some View { Background(color: .backgroundMain) { ScrollView { VStack(spacing: 0) { @@ -228,97 +347,156 @@ struct ChatProfileScreen: View { pinnedActions .toastClearance(toasts) } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - overflowMenu - } - } - .dialog(item: $dialogItem) - .invitePeople( - conversationID, - canInvite: conversation?.canInvite ?? false, - isPresented: $isInviting, - onManageInvites: { router.push(.inviteLinks(conversationID)) } - ) { chatID in - router.openSharedChat(chatID) - } - .sheet(isPresented: $isPickingMuteDuration) { - MuteChatSheet(conversationID: conversationID, isPresented: $isPickingMuteDuration) - } - .sheet(isPresented: $isShowingE2ee) { - E2eeLearnMoreSheet(kind: isPrivate ? .privateGroup : .group, isPresented: $isShowingE2ee) - } - .fullScreenCover(isPresented: $isReporting) { - NavigationStack { - ReportFlowScreen(target: .chat(conversationID)) + } + + // MARK: - Public - + + /// A public group's profile: the cover melting into a page of its own color. The colors come + /// from the best image there is (see ``PublicGroupBleedSource``). + private var publicScreen: some View { + let source = PublicGroupBleedSource( + conversation: conversation, + coverData: sessionContainer.profileAvatars.data(for: .groupCover(conversationID)), + avatarData: sessionContainer.profileAvatars.data(for: .chat(conversationID)) + ) + return PublicGroupProfileView( + content: publicContent(cover: source?.kind == .cover ? source?.image : nil), + hasPinned: showsPublicCTA, + portraitData: { sessionContainer.profileAvatars.data(for: $0) }, + loadPortrait: { portrait in + await sessionContainer.profileAvatars.load(userID: portrait.userID, picture: portrait.picture) + }, + onSelectPerson: { userID in + router.push(.userProfile(userID, origin: .groupMember)) + }, + onSeeChatters: { + isListingChatters = true + }, + onToken: { token in + Analytics.tokenInfoOpened(from: .openedFromChat, mint: token.mint) + router.push(.currencyInfo(token.mint)) + }, + accessory: { + if isMember { + ChatMuteStatusLabel(conversationID: conversationID, reservesSpace: false) + } + }, + actions: { + if canEdit { + Button("Edit Group") { + router.push(.editGroup(conversationID)) + } + .buttonStyle(PublicGroupOutlineButtonStyle()) + .accessibilityIdentifier("chat-profile-edit") + } + Button("Share") { + openInvite() + } + .buttonStyle(PublicGroupOutlineButtonStyle()) + .accessibilityLabel("Share group") + .accessibilityInputLabels(["Share"]) + .accessibilityIdentifier("chat-profile-share") + }, + hasFooter: isMember, + footer: { + if isMember { + publicLeaveButton + } + }, + pinned: { + publicPinnedActions + .toastClearance(toasts) } + ) + .bleedPalette(key: source?.key, image: source?.image, settings: PublicGroupLook.settings) + .task(id: conversation?.coverPicture?.blobID) { + await sessionContainer.profileAvatars.load(.groupCover(conversationID), picture: conversation?.coverPicture) } - .task { - // A favorite group can be one the user isn't in, which the feed doesn't hold. - let conversation = await conversationController.hydratedConversation(withID: conversationID) - await sessionContainer.profileAvatars.load(.chat(conversationID), picture: conversation?.picture) - await conversationController.fillCoverPicture(for: conversationID) + } + + /// Leave Chat, closing a public group's stack. + private var publicLeaveButton: some View { + Button { + dialogItem = leaveDialog() + } label: { + ButtonStateLabel("Leave Chat", state: isLeaving ? .loading : .normal) } - // No push says a request was decided, so every visit asks. Keyed on membership so an - // admission that lands while the profile is open stops the asking. - .task(id: isRequester) { - guard isRequester else { return } - if let fresh = await conversationController.refreshRequestedGroup(conversationID) { - sessionContainer.lobbyRequests.reconcile(with: fresh) + .buttonStyle(PublicGroupClosingButtonStyle()) + .disabled(isLeaving) + .accessibilityIdentifier("chat-profile-leave") + } + + private func publicContent(cover: UIImage?) -> PublicGroupProfileContent { + // Drawn at once: the amounts never name a token, and each tile holds its token's place + // until the name lands. + let tiles = requirements.map { + PublicGroupRequirementTile.tiles(for: $0, metadata: mintMetadata, display: ratesController.displayCurrency) + } ?? [] + return PublicGroupProfileContent( + id: conversationID.description, + title: title, + memberCount: conversation?.rosterSummary.memberCount, + description: conversation?.description, + avatarImageData: sessionContainer.profileAvatars.data(for: .chat(conversationID)), + avatarBlurhash: conversation?.picture?.thumbnailBlurhash, + cover: cover, + requirements: tiles, + namingTokens: requirements.map { namingTokens(tiles, in: $0) } ?? [], + yourBalance: requirements.flatMap { yourBalance(in: $0) }, + pulse: chatters.isEmpty ? nil : PublicGroupPulse(chatters: chatters, now: .now), + host: chatters.first(where: \.isCreator).map(GroupPortrait.init) + ) + } + + /// The tiles whose token is still being looked up: a mint other than the dollar token with no + /// name yet, until the lookup gives up. + private func namingTokens( + _ tiles: [PublicGroupRequirementTile], + in requirements: GroupBalanceRequirements + ) -> Set { + guard !mintNamesTimedOut else { return [] } + var naming: Set = [] + for tile in tiles { + let rule: MinimumBalanceRequirement? + switch tile.purpose { + case .joinAndChat, .join: rule = requirements.join + case .chat: rule = requirements.chat } - } - .task(id: requestedAt != nil ? conversation?.creator : nil) { - guard requestedAt != nil, let creator = conversation?.creator else { return } - hostName = try? await sessionContainer.flipClient.fetchProfile( - userID: creator, - owner: session.ownerKeyPair - ).displayName - } - // Waits for the conversation to land before asking: a private group's sample is denied. - .task(id: conversation.map { $0.isPrivate && $0.creator == conversationController.selfUserID }) { - guard let conversation, conversation.isPrivate, conversation.creator == conversationController.selfUserID else { return } - await sessionContainer.hostedLobbies.refresh(conversationID) - } - // A private group's members read its roster in place of the denied sample. - .task(id: isPrivate && isMember) { - guard isPrivate, isMember else { return } - await sessionContainer.groupRosters.refresh(conversationID) - } - // GetRoster is off server-side and SampleChatters refuses private groups, so the grid falls - // back to the people this device has seen post. - .task(id: isPrivate && isMember) { - guard isPrivate, isMember else { return } - senders = conversationController.senderIDs(in: conversationID) - let creator = conversation?.creator.map { [$0] } ?? [] - await sessionContainer.knownAuthors.resolve(creator + [conversationController.selfUserID] + senders) - } - .task(id: conversation.map { $0.isPrivate } ) { - guard let conversation, !conversation.isPrivate else { return } - await loadChatters() - } - // Name the requirements in the token they ask for. The mint may be one the user holds - // nothing of, so the local store can miss and the fetch is what fills it. - .task(id: namedMints) { - var metadata: [PublicKey: StoredMintMetadata] = [:] - for mint in namedMints { - if let stored = session.storedMintMetadata(for: mint) { - metadata[mint] = stored - } else if let fetched = try? await session.fetchMintMetadata(mint: mint) { - metadata[mint] = fetched - } + if let mint = rule?.mints.first, mint != .usdf, mintMetadata[mint] == nil { + naming.insert(tile.purpose) } - mintMetadata = metadata } - .task(id: namedMints) { - mintNamesTimedOut = false - try? await Task.sleep(for: .seconds(1.5)) - guard !Task.isCancelled else { return } - mintNamesTimedOut = true + return naming + } + + /// Whether the pinned Open Chat button shows: not from the chat itself, where the chat is one + /// back away. + private var showsPublicCTA: Bool { + origin != .chat && conversation != nil + } + + @ViewBuilder + private var publicPinnedActions: some View { + if showsPublicCTA { + Button("Open Chat") { showChat() } + .buttonStyle(PublicGroupCapsuleButtonStyle()) + .accessibilityIdentifier("group-profile-cta") + .padding(.horizontal, PublicGroupCapsuleButtonStyle.sideInset) + .padding(.top, 12) + .padding(.bottom, 8) } } // MARK: - Chatting - + /// Opens the profile picked in the chatter list once its tray has gone, so the push lands on + /// this screen rather than under the tray. + private func openPickedChatter() { + guard let userID = pickedChatter else { return } + pickedChatter = nil + router.push(.userProfile(userID, origin: .groupMember)) + } + /// Best effort: a sample that fails to load leaves the section hidden, as an empty one does. private func loadChatters() async { do { @@ -528,7 +706,8 @@ struct ChatProfileScreen: View { Label { Text(PrivateGroupCopy.summary(memberCount: conversation?.rosterSummary.memberCount ?? 0)) } icon: { - Image(systemName: "lock.fill") + Image.icon(.lock) + .iconSize(14) } .labelStyle(.titleAndIcon) Text("The host approves who joins") @@ -622,7 +801,7 @@ struct ChatProfileScreen: View { // MARK: - Share - private var shareButton: some View { - ProfileActionCircle(image: Image.asset(.shareOS)) { + ProfileActionCircle(image: Image.icon(.shareOs)) { openInvite() } .accessibilityLabel("Share group") @@ -722,6 +901,40 @@ struct ChatProfileScreen: View { } } +/// Everyone in a public group's chatter sample, opened from the pulse card's facepile: the +/// Chatting grid a public group's profile drew before the pulse card, in a tray that scrolls once +/// the grid outgrows it. +private struct ChattersTray: View { + + let chatters: [SampledChatter] + let onSelect: (UserID) -> Void + + @State private var flow = TrayFlow(root: Step.grid) + + var body: some View { + TrayStack(flow: flow, actions: { _ in [] }) { _ in + TrayPage { + ScrollView { + GroupChattingGrid(chatters: chatters, onSelect: onSelect) + // The grid insets itself for a full-width page; the tray's card already does. + .padding(.horizontal, -ProfileHeaderMetrics.inset) + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + .frame(maxHeight: Self.maxGridHeight) + } + } + } + + /// The tallest the grid gets before it scrolls: its title and about four rows, which fits the + /// smallest phone. + private static let maxGridHeight: CGFloat = 440 + + private enum Step: Hashable { + case grid + } +} + /// Where a group's profile was opened from. nonisolated enum ChatProfileOrigin: Hashable { /// The group chat's own navigation title, so the chat sits underneath. diff --git a/Flipcash/Core/Screens/Profile/E2eeLearnMoreSheet.swift b/Flipcash/Core/Screens/Profile/E2eeLearnMoreSheet.swift index 3a0a24282..4bf585ca0 100644 --- a/Flipcash/Core/Screens/Profile/E2eeLearnMoreSheet.swift +++ b/Flipcash/Core/Screens/Profile/E2eeLearnMoreSheet.swift @@ -6,7 +6,6 @@ // import SwiftUI -import UIKit import FlipcashUI /// Which chat an encryption notice speaks for: a DM or a private group, which are end-to-end @@ -26,49 +25,28 @@ struct E2eeLearnMoreSheet: View { @Binding var isPresented: Bool var body: some View { - PartialSheet(background: .backgroundMain) { - VStack(spacing: 0) { - HStack { - Spacer() - CloseButton(style: .glass, binding: $isPresented) - } - - Image(systemName: lockSymbol) - .font(.system(size: 32, weight: .semibold)) - .foregroundStyle(Color.white.opacity(0.7)) - .frame(width: 70, height: 70) - .background(Color.white.opacity(0.08), in: Circle()) - .padding(.top, 8) - - Text(title) - .font(.appTextXL) - .foregroundStyle(Color.textMain) - .multilineTextAlignment(.center) - .padding(.top, 20) - - // `fixedSize` for the reason ``MuteChatSheet`` gives: `PartialSheet` proposes the - // full screen height first, and a short proposal would otherwise truncate the text. - Text(message) - .font(.default(size: 15, weight: .medium)) - .lineSpacing(3) - .foregroundStyle(Color.white.opacity(0.7)) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 16) - - VStack(spacing: 16) { - section("ENCRYPTED", items: encrypted, isEncrypted: true) - section("NOT ENCRYPTED", items: notEncrypted, isEncrypted: false) - } - .padding(.top, 32) + Tray(title: title, message: message, icon: icon, actions: actions) { + VStack(spacing: 12) { + section("ENCRYPTED", items: encrypted, isEncrypted: true) + section("NOT ENCRYPTED", items: notEncrypted, isEncrypted: false) } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, bottomPadding) } .accessibilityIdentifier("e2ee-learn-more-\(kind)") } + /// The lock: Central's for an encrypted chat, the SF open lock for a public group, which + /// Central lacks. + private var icon: TrayIcon { + switch kind { + case .dm, .privateGroup: .central(.lock) + case .group: .image(Image(systemName: "lock.open")) + } + } + + private var actions: [TrayAction] { + [TrayAction("Got It") { isPresented = false }] + } + private func section(_ heading: String, items: [String], isEncrypted: Bool) -> some View { VStack(alignment: .leading, spacing: 12) { Text(heading) @@ -77,51 +55,37 @@ struct E2eeLearnMoreSheet: View { .foregroundStyle(Color.white.opacity(0.45)) ForEach(items, id: \.self) { item in - HStack(spacing: 12) { + HStack(alignment: .firstTextBaseline, spacing: 10) { bullet(isEncrypted: isEncrypted) Text(item) - .font(.default(size: 15, weight: .medium)) + .font(.default(size: 15)) .foregroundStyle(Color.white.opacity(0.9)) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) } } } - .padding(16) .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.white.opacity(0.04), in: RoundedRectangle(cornerRadius: 6)) + .padding(16) + .background(Color.white.opacity(0.05), in: .rect(cornerRadius: 16, style: .continuous)) } private func bullet(isEncrypted: Bool) -> some View { - Group { - if isEncrypted { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(Color.backgroundMain, Color.success) - } else { - Image(systemName: "minus.circle.fill") - .foregroundStyle(Color.white.opacity(0.7), Color.white.opacity(0.12)) - } - } - .symbolRenderingMode(.palette) - .font(.system(size: 18, weight: .semibold)) - .frame(width: 18, height: 18) + Image.icon(isEncrypted ? .circleCheck : .circleMinus) + .iconSize(18) + .foregroundStyle(isEncrypted ? Color.success : Color.white.opacity(0.5)) + // Sits the glyph on the text's first baseline. + .alignmentGuide(.firstTextBaseline) { $0[VerticalAlignment.center] + 5 } } - private var lockSymbol: String { - switch kind { - case .dm, .privateGroup: "lock.fill" - case .group: "lock.open.fill" - } - } - - private var title: String { + private var title: LocalizedStringResource { switch kind { case .dm, .privateGroup: "Your Messages Are Private" case .group: "Public Groups Are Not Encrypted" } } - private var message: String { + private var message: LocalizedStringResource { switch kind { case .dm: "Messages and media in this chat are end-to-end encrypted. Only you and the person you\u{2019}re chatting with can read them." @@ -162,17 +126,25 @@ struct E2eeLearnMoreSheet: View { ] } } - - /// The design's 16 under the last section, less what the sheet already holds back for the home - /// indicator. - private var bottomPadding: CGFloat { - let reserved = UIApplication.shared.currentKeyWindow?.safeAreaInsets.bottom ?? 0 - return max(0, 16 - reserved) - } } /// The encryption notice pinned under a chat's profile (nodes 10557:1304 and 10557:1643): a lock /// and one line, with Learn More opening ``E2eeLearnMoreSheet``. +/// Figma's Central lock for an encrypted chat; the SF open lock for a public group, which Central lacks. +private struct E2eeLockGlyph: View { + let kind: E2eeNoticeKind + let size: CGFloat + + var body: some View { + switch kind { + case .dm, .privateGroup: + Image.icon(.lock).iconSize(size * 1.2) + case .group: + Image(systemName: "lock.open.fill").font(.system(size: size, weight: .semibold)) + } + } +} + struct E2eeFooter: View { let kind: E2eeNoticeKind @@ -185,8 +157,7 @@ struct E2eeFooter: View { } label: { VStack(spacing: 4) { HStack(spacing: 5) { - Image(systemName: kind == .group ? "lock.open.fill" : "lock.fill") - .font(.system(size: 12, weight: .semibold)) + E2eeLockGlyph(kind: kind, size: 14) .foregroundStyle(Color.white.opacity(0.5)) Text(kind == .group ? "Public groups are not end-to-end encrypted" : "Messages are end-to-end encrypted") .font(.default(size: 13, weight: .medium)) @@ -202,7 +173,7 @@ struct E2eeFooter: View { } .buttonStyle(.plain) .accessibilityIdentifier("e2ee-footer-\(kind)") - .sheet(isPresented: $isShowingSheet) { + .tray(isPresented: $isShowingSheet) { E2eeLearnMoreSheet(kind: kind, isPresented: $isShowingSheet) } } diff --git a/Flipcash/Core/Screens/Profile/MuteChatSheet.swift b/Flipcash/Core/Screens/Profile/MuteChatSheet.swift index 8eaa0717b..6b30c4768 100644 --- a/Flipcash/Core/Screens/Profile/MuteChatSheet.swift +++ b/Flipcash/Core/Screens/Profile/MuteChatSheet.swift @@ -6,12 +6,10 @@ // import SwiftUI -import UIKit import FlipcashCore import FlipcashUI -/// How long to silence a chat's notifications for. Drawn as the chats flow's action rows, the way -/// ``ShareToChatsSheet`` draws its two. +/// How long to silence a chat's notifications for, as a tray of option rows. /// /// The durations are the client's choice; the contract admits only two mute shapes — until a /// timestamp, or forever — so every timed option is the same shape with a different offset. @@ -72,72 +70,54 @@ struct MuteChatSheet: View { offersUnmute ? [("Never", .never)] + Self.durations : Self.durations } + /// The row picked for the CTA to apply, in the select-then-confirm pattern. + @State private var selection: Option? + var body: some View { - PartialSheet(background: .backgroundMain) { - VStack(spacing: 20) { - header - - // What muting does and doesn't do, the way WhatsApp captions the same sheet. Worth - // saying here because the answer isn't obvious and is easy to get wrong: a muted - // chat still delivers, still stores, and still counts unread. - Text("You'll still receive messages and see unread counts. Only notifications are silenced") - .font(.appTextSmall) - .foregroundStyle(Color.textSecondary) - .multilineTextAlignment(.center) - // The only flexible row in this stack, so a height proposal short of what the - // content needs lands here -- and a `Text` answers that by dropping lines and - // ellipsizing. `PartialSheet` opens at the full screen height and corrects to - // its measured height, and the row count changes with the unmute row, so short - // proposals do happen. `fixedSize` makes the wrapped height a floor instead. - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity) - - VStack(spacing: 12) { - ForEach(options, id: \.title) { row in - ChatActionRow( - icon: .system(icon(for: row.option)), - title: row.title, - accessibilityIdentifier: "mute-chat-\(identifier(for: row.option))" - ) { - guard !isWorking else { return } - Task { await apply(row.option) } - } + Tray( + title: "Mute Notifications", + // What muting does and doesn't do, the way WhatsApp captions the same sheet. Worth + // saying here because the answer isn't obvious and is easy to get wrong: a muted chat + // still delivers, still stores, and still counts unread. + message: "You'll still receive messages and see unread counts. Only notifications are silenced", + icon: .central(.bell2Off), + actions: actions + ) { + VStack(spacing: TrayOptionRow.gap) { + ForEach(options, id: \.title) { row in + TrayOptionRow( + row.title, + icon: icon(for: row.option), + isSelected: selection == row.option + ) { + guard !isWorking else { return } + selection = row.option } + .accessibilityIdentifier("mute-chat-\(identifier(for: row.option))") } } - .padding(.horizontal, 16) - .padding(.top, 20) - .padding(.bottom, bottomPadding) - } - } - - /// The sheet's own title bar — ``PartialSheet`` sizes itself to its content, so there is no - /// navigation bar to hang a title on. Mirrors ``ShareToChatsSheet/header``. - private var header: some View { - ZStack { - Text("Mute Notifications") - .font(.appBarButton) - .foregroundStyle(Color.textMain) - - HStack { - Spacer() - CloseButton(style: .glass, binding: $isPresented) - } } } - /// The design's 16 under the last row, less whatever the sheet already holds back for the home - /// indicator. - private var bottomPadding: CGFloat { - let reserved = UIApplication.shared.currentKeyWindow?.safeAreaInsets.bottom ?? 0 - return max(0, 16 - reserved) + /// The CTA that applies the selected row, worded for an unmute when that is the pick. + private var actions: [TrayAction] { + [ + TrayAction( + selection == .never ? "Unmute" : "Mute", + state: isWorking ? .loading : .normal, + isEnabled: selection != nil + ) { + guard let selection else { return } + Task { await apply(selection) } + }, + ] } - private func icon(for option: Option) -> SystemSymbol { + private func icon(for option: Option) -> Icon { switch option { case .until: .clock - case .forever: .bellSlash - case .never: .bell + case .forever: .bell2Off + case .never: .bell2 } } diff --git a/Flipcash/Core/Screens/Profile/PublicGroupCards.swift b/Flipcash/Core/Screens/Profile/PublicGroupCards.swift new file mode 100644 index 000000000..ac74ebae8 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/PublicGroupCards.swift @@ -0,0 +1,312 @@ +// +// PublicGroupCards.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// What it takes to join and chat in a public group: one tile with the amount as its hero when +/// joining and chatting ask the same, two side by side when they differ, and the viewer's balance +/// under them. +struct PublicGroupRequirementsView: View { + + let tiles: [PublicGroupRequirementTile] + /// The tiles whose token is still being looked up, which hold a greyed chip in its place. + let namingTokens: Set + let yourBalance: String? + let onToken: (PublicGroupToken) -> Void + + @Environment(\.bleedPalette) private var palette + + var body: some View { + content + // A token's name landing swaps its placeholder for the chip. + .animation(.easeOut(duration: 0.2), value: tiles) + .accessibilityElement(children: .contain) + .accessibilityLabel("Requirements") + } + + @ViewBuilder + private var content: some View { + if tiles.count == 1, let tile = tiles.first { + VStack(alignment: .leading, spacing: 0) { + tileContent(tile, isWide: true) + if let yourBalance { + PublicGroupHairline() + .padding(.vertical, PublicGroupLook.dividerGap) + footer(yourBalance, style: palette.controlSecondaryStyle) + } + } + .padding(PublicGroupLook.cardPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(palette.controlTint, in: PublicGroupLook.cardShape) + } else { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: PublicGroupLook.cardGap) { + ForEach(tiles) { tile in + tileContent(tile, isWide: false) + .padding(PublicGroupLook.cardPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(palette.controlTint, in: PublicGroupLook.cardShape) + } + } + if let yourBalance { + footer(yourBalance, style: palette.secondaryStyle) + .padding(.horizontal, PublicGroupLook.cardPadding) + } + } + } + } + + private func tileContent(_ tile: PublicGroupRequirementTile, isWide: Bool) -> some View { + VStack(alignment: .leading, spacing: 0) { + if isWide { + HStack(alignment: .firstTextBaseline, spacing: 8) { + amount(tile) + tokenButton(tile) + } + } else { + amount(tile) + tokenButton(tile) + } + Text(tile.purpose.caption) + .font(.appTextSmall) + .foregroundStyle(palette.controlSecondaryStyle) + // The amount's label already reads it. + .accessibilityHidden(true) + } + } + + private func amount(_ tile: PublicGroupRequirementTile) -> some View { + Text(tile.amount) + .font(.appDisplayMedium) + .foregroundStyle(palette.primaryText) + .lineLimit(1) + .minimumScaleFactor(0.5) + .accessibilityLabel(amountLabel(tile)) + // The box has no visible title, so its lead line is the heading. + .accessibilityAddTraits(.isHeader) + .accessibilityIdentifier(tile.purpose == .chat ? "group-profile-chat-minimum" : "group-profile-join-minimum") + } + + /// The amount, its token and what it's for, read as one: "$100 of Jeffy to join and chat". + private func amountLabel(_ tile: PublicGroupRequirementTile) -> String { + guard let token = tile.token else { return "\(tile.amount) \(tile.purpose.caption)" } + return "\(tile.amount) of \(token.name) \(tile.purpose.caption)" + } + + @ViewBuilder + private func tokenButton(_ tile: PublicGroupRequirementTile) -> some View { + if let token = tile.token { + Button { + onToken(token) + } label: { + HStack(spacing: 6) { + Text("of") + .font(.appTextMedium) + .foregroundStyle(palette.controlSecondaryStyle) + TokenIconWithName( + url: token.imageURL, + monogramID: token.mint.base58, + name: token.name, + iconSize: 16 + ) + } + // Grown to a 44pt target without moving anything. + .padding(.vertical, 12) + .contentShape(Rectangle()) + .padding(.vertical, -12) + } + .buttonStyle(PublicGroupDimButtonStyle()) + // The amount's label already names the token; this says where the button goes. + .accessibilityLabel("\(token.name) currency info") + .accessibilityIdentifier(Self.tokenIdentifier(for: tile.purpose)) + } else if namingTokens.contains(tile.purpose) { + tokenPlaceholder + } + } + + /// Holds the token chip's place, greyed, while the token's name loads. + private var tokenPlaceholder: some View { + HStack(spacing: 6) { + Text("of") + Text("Token") + .redacted(reason: .placeholder) + } + .font(.appTextMedium) + .foregroundStyle(palette.controlSecondaryStyle) + .accessibilityHidden(true) + } + + /// A split pair's tiles each name the token, so their buttons need their own identifiers. + private static func tokenIdentifier(for purpose: PublicGroupRequirementTile.Purpose) -> String { + switch purpose { + case .joinAndChat: "group-profile-token" + case .join: "group-profile-token-join" + case .chat: "group-profile-token-chat" + } + } + + private func footer(_ yourBalance: String, style: AnyShapeStyle) -> some View { + // Greyed while a token is unnamed, so "$10" never turns into "$10 of NYC" in front of the viewer. + let isNaming = !namingTokens.isEmpty + return HStack { + Text("Your Balance") + Spacer() + Text(yourBalance) + .redacted(reason: isNaming ? .placeholder : []) + .accessibilityHidden(isNaming) + } + .font(.appTextSmall) + .foregroundStyle(style) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("group-profile-your-balance") + } + +} + +/// Who has been chatting in a public group lately: a facepile that opens everyone in the sample, +/// how many people that is, when the last message was, and the host, who opens their profile. +struct PublicGroupPulseCard: View { + + let pulse: PublicGroupPulse + let host: GroupPortrait? + let memberCount: UInt64? + let portraitData: (UserID) -> Data? + let loadPortrait: (GroupPortrait) async -> Void + let onSelect: (UserID) -> Void + /// Opens the full list of the group's sampled chatters. + let onSeeAll: () -> Void + + @Environment(\.bleedPalette) private var palette + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if !pulse.faces.isEmpty { + facepile + .padding(.bottom, PublicGroupLook.facepileToHeadline) + } + + Text(PublicGroupCopy.chatting(count: pulse.chattingCount)) + .font(.appTextMedium) + .foregroundStyle(palette.primaryText) + // The box has no visible title, so its lead line is the heading. + .accessibilityAddTraits(.isHeader) + + if let meta = PublicGroupCopy.pulseMeta(lastMessageAt: pulse.lastMessageAt, memberCount: memberCount, now: .now) { + Text(meta) + .font(.appTextCaption) + .foregroundStyle(palette.controlSecondaryStyle) + .padding(.top, PublicGroupLook.headlineToMeta) + } + + if let host { + PublicGroupHairline() + .padding(.vertical, PublicGroupLook.dividerGap) + hostRow(host) + } + } + .padding(PublicGroupLook.cardPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(palette.controlTint, in: PublicGroupLook.cardShape) + .accessibilityElement(children: .contain) + .accessibilityLabel("Chatting recently") + .accessibilityIdentifier("group-profile-chatting") + } + + // MARK: - Facepile - + + /// One target for the whole pile, "+N" included: the faces overlap, so a tap meant for one + /// could land on its neighbour. + private var facepile: some View { + Button(action: onSeeAll) { + HStack(spacing: -Self.faceOverlap) { + ForEach(pulse.faces, id: \.userID) { portrait in + ContactAvatarView( + id: portrait.userID.uuidString, + displayName: portrait.name, + imageData: portraitData(portrait.userID), + blurhash: portrait.picture?.thumbnailBlurhash, + size: Self.faceSize + ) + .overlay { Circle().strokeBorder(palette.controlTint, lineWidth: 2) } + .task(id: portrait.userID) { await loadPortrait(portrait) } + } + if pulse.overflow > 0 { + Text("+\(pulse.overflow)") + .font(.appTextHeading) + .foregroundStyle(palette.primaryText) + .frame(width: Self.faceSize, height: Self.faceSize) + .background(palette.hairline, in: Circle()) + .overlay { Circle().strokeBorder(palette.controlTint, lineWidth: 2) } + } + } + // Grown to a 44pt-tall target without moving anything. + .padding(.vertical, (Self.hitHeight - Self.faceSize) / 2) + .contentShape(Rectangle()) + .padding(.vertical, -(Self.hitHeight - Self.faceSize) / 2) + } + .buttonStyle(PublicGroupDimButtonStyle()) + .accessibilityLabel("See everyone chatting") + .accessibilityIdentifier("group-profile-see-chatters") + } + + // MARK: - Host - + + private func hostRow(_ host: GroupPortrait) -> some View { + Button { + onSelect(host.userID) + } label: { + HStack(alignment: .center, spacing: 12) { + ContactAvatarView( + id: host.userID.uuidString, + displayName: host.name, + imageData: portraitData(host.userID), + blurhash: host.picture?.thumbnailBlurhash, + size: Self.hostSize + ) + .overlay(alignment: .bottomTrailing) { hostMark } + + HStack(spacing: 4) { + Text("Hosted by") + .foregroundStyle(palette.controlSecondaryStyle) + Text(host.name) + .foregroundStyle(palette.primaryText) + .lineLimit(1) + } + .font(.appTextSmall) + + Spacer(minLength: 8) + + Image.icon(.chevronRightMedium) + .iconSize(16) + .foregroundStyle(palette.controlSecondaryStyle) + } + // Grown to a 44pt target without moving anything, so the row's visible space is the box's. + .padding(.vertical, (Self.hitHeight - Self.hostSize) / 2) + .contentShape(Rectangle()) + .padding(.vertical, -(Self.hitHeight - Self.hostSize) / 2) + } + .buttonStyle(PublicGroupDimButtonStyle()) + .accessibilityLabel("Hosted by \(host.name)") + .task(id: host.userID) { await loadPortrait(host) } + } + + private var hostMark: some View { + Image.icon(.hand5Finger) + .iconSize(9) + .foregroundStyle(palette.primaryText) + .frame(width: 16, height: 16) + .background(palette.background, in: Circle()) + .overlay { Circle().strokeBorder(palette.controlTint, lineWidth: 1.5) } + .offset(x: 3, y: 3) + .accessibilityHidden(true) + } + + private static var faceSize: CGFloat { 36 } + private static var faceOverlap: CGFloat { 10 } + private static var hitHeight: CGFloat { 44 } + private static var hostSize: CGFloat { 28 } +} diff --git a/Flipcash/Core/Screens/Profile/PublicGroupCopy.swift b/Flipcash/Core/Screens/Profile/PublicGroupCopy.swift new file mode 100644 index 000000000..fb9fd40c9 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/PublicGroupCopy.swift @@ -0,0 +1,155 @@ +// +// PublicGroupCopy.swift +// Flipcash +// + +import Foundation +import FlipcashCore +import FlipcashStore + +/// The words the public group profile shows. +enum PublicGroupCopy { + + /// The line under the group's name: "Public group · 22 members", or "Public group" before the + /// count is known. + static func summary(memberCount: UInt64?) -> String { + guard let memberCount, memberCount > 0 else { return "Public group" } + return "Public group · \(PrivateGroupCopy.memberCount(memberCount))" + } + + /// The pulse card's headline: "6 chatting this week", or "Quiet this week" when nobody is. + static func chatting(count: Int) -> String { + count > 0 ? "\(count) chatting this week" : "Quiet this week" + } + + /// How long ago the last message was: "just now", "2m ago", "3h ago", "1 day ago" or "12 days ago". + static func lastMessage(at date: Date, now: Date) -> String { + let minutes = Int(max(0, now.timeIntervalSince(date)) / 60) + switch minutes { + case ..<1: return "just now" + case ..<60: return "\(minutes)m ago" + case ..<(60 * 24): return "\(minutes / 60)h ago" + default: + let days = minutes / (60 * 24) + return days == 1 ? "1 day ago" : "\(days) days ago" + } + } + + /// The pulse card's meta line: "Last message 2m ago · 22 members", leaving out whichever part + /// isn't known, and `nil` when neither is. + static func pulseMeta(lastMessageAt: Date?, memberCount: UInt64?, now: Date) -> String? { + var parts: [String] = [] + if let lastMessageAt { + parts.append("Last message \(lastMessage(at: lastMessageAt, now: now))") + } + if let memberCount, memberCount > 0 { + parts.append(PrivateGroupCopy.memberCount(memberCount)) + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } +} + +/// What a public group's pulse card says about the last week of chat, read from the server's +/// sample of at most 100 chatters rather than the full roster. +nonisolated struct PublicGroupPulse: Equatable { + + /// The faces the card draws at most. + static let faceLimit = 5 + + /// How far back "this week" reaches. + static let window: TimeInterval = 7 * 24 * 60 * 60 + + /// The people who chatted this week, newest first, up to ``faceLimit``. + let faces: [GroupPortrait] + /// How many sampled chatters sent a message within the window. + let chattingCount: Int + /// When the most recent sampled message was sent, even if it falls outside the window. + let lastMessageAt: Date? + + /// How many chatters the facepile doesn't show. + var overflow: Int { max(chattingCount - faces.count, 0) } + + /// The pulse of `chatters` as of `now`. + init(chatters: [SampledChatter], now: Date) { + let cutoff = now.addingTimeInterval(-Self.window) + let recent = chatters + .filter { ($0.lastSentAt ?? .distantPast) >= cutoff } + .sorted { ($0.lastSentAt ?? .distantPast) > ($1.lastSentAt ?? .distantPast) } + faces = recent.prefix(Self.faceLimit).map(GroupPortrait.init) + chattingCount = recent.count + lastMessageAt = chatters.compactMap(\.lastSentAt).max() + } +} + +/// A token a requirement names, for the tile's "of ◉ Jeffy". +nonisolated struct PublicGroupToken: Equatable { + /// The token's mint, which opens its info page. + let mint: PublicKey + /// The token's display name. + let name: String + /// The token's logo, when it has one. + let imageURL: URL? +} + +/// One tile of the requirements card: the amount to hold and what it is for. +nonisolated struct PublicGroupRequirementTile: Identifiable, Equatable { + + enum Purpose: Equatable { + case joinAndChat, join, chat + + /// The caption under the amount. + var caption: String { + switch self { + case .joinAndChat: "to join and chat" + case .join: "to join" + case .chat: "to chat" + } + } + } + + let purpose: Purpose + /// The amount, as the gate panel words it and restated in the display currency: "$100". + let amount: String + /// The token the amount is held in; `nil` for a dollar-token or any-holding rule, or one whose + /// name isn't known. + let token: PublicGroupToken? + + var id: Purpose { purpose } + + /// The tiles for `requirements`, with amounts in `display`: one reading "to join and chat" when + /// both rules are the same amount in the same tokens, else one per rule. + // On the main actor, where the app's `GroupBalanceRequirements.formatted` lives. + @MainActor + static func tiles( + for requirements: GroupBalanceRequirements, + metadata: [PublicKey: StoredMintMetadata], + display: DisplayCurrency + ) -> [PublicGroupRequirementTile] { + if let join = requirements.join, join == requirements.chat { + return [tile(.joinAndChat, join, metadata, display)] + } + var tiles: [PublicGroupRequirementTile] = [] + if let join = requirements.join { tiles.append(tile(.join, join, metadata, display)) } + if let chat = requirements.chat { tiles.append(tile(.chat, chat, metadata, display)) } + return tiles + } + + @MainActor + private static func tile( + _ purpose: Purpose, + _ requirement: MinimumBalanceRequirement, + _ metadata: [PublicKey: StoredMintMetadata], + _ display: DisplayCurrency + ) -> PublicGroupRequirementTile { + var token: PublicGroupToken? + if let mint = requirement.mints.first, mint != .usdf, let stored = metadata[mint] { + token = PublicGroupToken(mint: mint, name: stored.name, imageURL: stored.imageURL) + } + return PublicGroupRequirementTile( + purpose: purpose, + // No token name: the tile names the token beside the amount. + amount: GroupBalanceRequirements.formatted(requirement, mintName: nil, in: display), + token: token + ) + } +} diff --git a/Flipcash/Core/Screens/Profile/PublicGroupLook.swift b/Flipcash/Core/Screens/Profile/PublicGroupLook.swift new file mode 100644 index 000000000..e1bb475f2 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/PublicGroupLook.swift @@ -0,0 +1,151 @@ +// +// PublicGroupLook.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The numbers of the public group profile's cover-colored page. +enum PublicGroupLook { + + /// How strongly the cover's own color is boosted before it becomes the page. + static let saturation: Double = 1.2 + + /// The OKLab lightness the page is pulled toward. + static let lightnessDark: Double = 0.1 + + /// How far toward ``lightnessDark`` the cover's color moves. + static let lightnessPull: Double = 0.6 + + /// The length of the melt from the cover into the page. + static let fadeHeight: CGFloat = 214 + + /// The cover's height from the top of the screen, under the status and navigation bars. + static let coverHeight: CGFloat = 320 + + /// How far below the navigation bar's bottom edge the identity (the avatar onward) starts; the + /// cover stays put, so a larger value moves the avatar down into the melt. + static let contentOffset: CGFloat = 4 + + /// Whether the identity's avatar is drawn inside a ring of the page's color. + static let avatarStroke: Bool = true + + /// The width of that ring. + static let avatarStrokeWidth: CGFloat = 3 + + // MARK: Boxes and spacing + + /// The corner radius of every box: the requirement tiles and the pulse card. + static let cardRadius: CGFloat = 24 + + /// The padding inside every box. + static let cardPadding: CGFloat = 16 + + /// The space above and below the hairline in a box, between its summary and the row under it. + static let dividerGap: CGFloat = 12 + + /// The gap from the pulse card's facepile to its headline. + static let facepileToHeadline: CGFloat = 12 + + /// The gap from the pulse card's headline to its meta line. + static let headlineToMeta: CGFloat = 2 + + /// The gap between boxes, stacked and side by side. + static let cardGap: CGFloat = 12 + + /// The gap above the closing action under the boxes, such as Leave Chat. + static let leaveGap: CGFloat = 12 + + /// Whether Leave Chat's label is the white control label rather than the quieter secondary tint. + static let leaveLabelIsWhite: Bool = false + + /// The gap from the last element of the identity to the first box. + static let identityToCards: CGFloat = 40 + + /// The margin the page's content sits inside, on each side. + static let sideMargin: CGFloat = 20 + + /// The gap from the avatar to the name. + static let avatarToName: CGFloat = 12 + + /// The gap from the name to the member count. + static let nameToMeta: CGFloat = 4 + + /// The gap from the member count to the bio. + static let metaToBio: CGFloat = 8 + + /// The gap from the last text of the identity to the Share and Edit pills. + static let bioToActions: CGFloat = 16 + + /// The corner radius of the Share and Edit pills; half their height is a capsule. + static let pillRadius: CGFloat = 22 + + /// The outline every box is drawn with. + static var cardShape: RoundedRectangle { + .rect(cornerRadius: cardRadius, style: .continuous) + } + + /// The palette settings those numbers make. + static var settings: BleedSettings { + BleedSettings(saturation: saturation, lightnessDark: lightnessDark, lightnessPull: lightnessPull) + } + + /// Where the glow behind the avatar centers when the page is colored from the avatar, as a + /// fraction of the cover. + static let glowCenter = UnitPoint(x: 0.15, y: 0.5) + /// The radius of that glow. + static let glowRadius: CGFloat = 300 +} + +/// The image a public group's page is colored from, and whether it is drawn as the cover. +struct PublicGroupBleedSource { + + enum Kind: Equatable { + /// The group's cover, drawn at the top of the page. + case cover + /// The group's avatar, which colors the page but is not drawn as a cover. + case avatar + } + + /// Identifies the image's pixels for the color cache. + let key: String + let image: UIImage + let kind: Kind + + /// The best image there is: the cover's bytes, the cover's blurhash, then the avatar's bytes or + /// blurhash. `nil` when the group has none of them. + @MainActor + init?(conversation: Conversation?, coverData: Data?, avatarData: Data?) { + if let cover = conversation?.coverPicture { + let coverKey = "cover-\(cover.blobID)" + if let coverData, let image = ContactAvatarCache.shared.image(forKey: coverKey, data: coverData) { + self.init(key: coverKey, image: image, kind: .cover) + return + } + if let hash = cover.thumbnailBlurhash, let image = BlurHashCache.shared.image(for: hash) { + self.init(key: "cover-hash-\(hash)", image: image, kind: .cover) + return + } + } + if let picture = conversation?.picture { + let avatarKey = "avatar-\(picture.thumbnailBlobID)" + if let avatarData, let image = ContactAvatarCache.shared.image(forKey: avatarKey, data: avatarData) { + self.init(key: avatarKey, image: image, kind: .avatar) + return + } + if let hash = picture.thumbnailBlurhash, let image = BlurHashCache.shared.image(for: hash) { + self.init(key: "avatar-hash-\(hash)", image: image, kind: .avatar) + return + } + } + return nil + } + + private init(key: String, image: UIImage, kind: Kind) { + self.key = key + self.image = image + self.kind = kind + } +} diff --git a/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift b/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift new file mode 100644 index 000000000..7dfa274df --- /dev/null +++ b/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift @@ -0,0 +1,604 @@ +// +// PublicGroupProfileView.swift +// Flipcash +// + +import SwiftUI +import UIKit +import FlipcashCore +import FlipcashUI + +/// Everything a public group's profile draws, as plain values. +struct PublicGroupProfileContent { + + /// Identifies the group for its monogram. + let id: String + /// The group's name. + let title: String + /// How many members the group has, when known. + let memberCount: UInt64? + /// The group's bio; it isn't persisted locally, so it can arrive after the rest. + let description: String? + /// The avatar's bytes, once loaded. + let avatarImageData: Data? + /// The avatar's placeholder while its bytes load. + let avatarBlurhash: String? + /// The cover drawn at the top; `nil` when the page is colored from the avatar instead. + let cover: UIImage? + /// The requirements card's tiles; empty hides the section. + let requirements: [PublicGroupRequirementTile] + /// The tiles whose token is still being looked up, which draw a greyed chip until it lands. + let namingTokens: Set + /// What the viewer holds toward the rule, stated for the card's footer. + let yourBalance: String? + /// The pulse card; `nil` hides the section. + let pulse: PublicGroupPulse? + /// The group's host, when the chatter sample names them. + let host: GroupPortrait? +} + +/// A public group's profile: its cover melting into a page of the cover's own color, the group's +/// identity and actions, then a stack of boxes: what it takes to join and chat, who has been +/// chatting, and the footer. +/// +/// Reads `\.bleedPalette`, so the caller provides it with `.bleedPalette(key:image:settings:)` on +/// this view. The view paints the page, the navigation container and the pinned bar's fade itself. +struct PublicGroupProfileView: View { + + let content: PublicGroupProfileContent + /// Whether the pinned bar has anything in it, which is when its fade belongs. + let hasPinned: Bool + /// The loaded avatar bytes of a person, if any. + let portraitData: (UserID) -> Data? + /// Loads a person's avatar. + let loadPortrait: (GroupPortrait) async -> Void + /// Opens a person's profile. + let onSelectPerson: (UserID) -> Void + /// Opens the full list of the group's sampled chatters. + let onSeeChatters: () -> Void + /// Opens a token's info page. + let onToken: (PublicGroupToken) -> Void + /// Sits beside the avatar, such as the chat's mute status. + @ViewBuilder let accessory: () -> Accessory + /// The outline pills under the bio. + @ViewBuilder let actions: () -> Actions + /// Whether ``footer`` has anything in it, which is when its box and the gap above it belong. + let hasFooter: Bool + /// The action under the boxes that closes the stack, such as Leave Chat. + @ViewBuilder let footer: () -> Footer + /// The bar pinned under the scroll. + @ViewBuilder let pinned: () -> Pinned + + @Environment(\.bleedPalette) private var palette + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// The status and navigation bars' height, which the cover runs under. + @State private var topInset: CGFloat = 0 + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + identity + + if hasBoxes { + VStack(spacing: 0) { + if hasCards { + VStack(spacing: PublicGroupLook.cardGap) { + if !content.requirements.isEmpty { + PublicGroupRequirementsView( + tiles: content.requirements, + namingTokens: content.namingTokens, + yourBalance: content.yourBalance, + onToken: onToken + ) + } + + if let pulse = content.pulse { + PublicGroupPulseCard( + pulse: pulse, + host: content.host, + memberCount: content.memberCount, + portraitData: portraitData, + loadPortrait: loadPortrait, + onSelect: onSelectPerson, + onSeeAll: onSeeChatters + ) + } + } + } + + if hasFooter { + footer() + .padding(.top, hasCards ? PublicGroupLook.leaveGap : 0) + } + } + .padding(.horizontal, PublicGroupLook.sideMargin) + .padding(.top, PublicGroupLook.identityToCards) + } + } + // Negative pulls the identity up under the bar; the cover hangs from the scroll's top, + // so it stays put either way. + .padding(.top, PublicGroupLook.contentOffset) + .padding(.bottom, 24) + // On the whole column, so a bio that arrives late moves the sections with the pills. + .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: content.description) + .background(alignment: .top) { cover } + } + .scrollIndicators(.hidden) + .onGeometryChange(for: CGFloat.self) { $0.safeAreaInsets.top } action: { topInset = $0 } + .background { palette.background.ignoresSafeArea() } + // The cover runs under the bar, so the edge effect only belongs once it has scrolled away. + .hidesTopScrollEdge(untilOffset: PublicGroupLook.coverHeight / 2) + .scrollEdgeBar(.bottom) { + pinned() + .background { pinnedFade } + } + .containerBackground(palette.background, for: .navigation) + .toolbarColorScheme(palette.colorScheme, for: .navigationBar) + .modifier(LegacyCoverBar(color: palette.background, coverScroll: max(coverBottom - topInset, 0))) + // After the scheme and the bar background, which give this screen its own bar appearance + // without the app's arrow. + .background { AppBackIndicator().frame(width: 0, height: 0) } + } + + private var hasBoxes: Bool { + hasCards || hasFooter + } + + /// Whether the requirements or the pulse card show, the boxes ``footer`` sits under. + private var hasCards: Bool { + !content.requirements.isEmpty || content.pulse != nil + } + + // MARK: - Cover - + + /// How far below the bars the name starts: the identity's offset, the ringed avatar and the + /// gap under it. + private var barsToName: CGFloat { + PublicGroupLook.contentOffset + Self.avatarSize + 2 * avatarRing + PublicGroupLook.avatarToName + } + + /// Where the cover ends, from the top of the screen: its tuned height, cut short where the name + /// starts, so the melt has reached the page's color by then and the name reads against the + /// page whatever the cover. + private var coverBottom: CGFloat { + min(PublicGroupLook.coverHeight, topInset + barsToName) + } + + /// The melt's length: at most the run from the bars to the cover's bottom, so the cover shows + /// in full behind the bars. + private var meltHeight: CGFloat { + min(PublicGroupLook.fadeHeight, max(coverBottom - topInset, 0)) + } + + private var cover: some View { + Color.clear + .frame(maxWidth: .infinity) + .frame(height: coverBottom) + // At its tuned height, so the picture's crop and the glow's center stay put and only + // the part under the name is cut away. + .overlay(alignment: .top) { + coverImage + .frame(height: PublicGroupLook.coverHeight) + } + .clipped() + // After the clip, so the color runs a little past the cover's edges and no hairline of + // the cover shows at the seam. + .overlay(alignment: .bottom) { + BleedFade(color: palette.background, height: meltHeight) + } + // Grows upward from its bottom edge when the scroll view is pulled past its top, as + // `ProfileCoverBanner` does, the fade riding along. The lift under the bars is in the + // same effect: an `.offset` after it stops the effect following the scroll. + .visualEffect { [topInset] content, proxy in + let overscroll = max(0, proxy.frame(in: .scrollView).minY) + let height = max(proxy.size.height, 1) + return content + .scaleEffect((height + overscroll) / height, anchor: .bottom) + .offset(y: -topInset) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("profile-cover") + } + + @ViewBuilder + private var coverImage: some View { + if let cover = content.cover { + Image(uiImage: cover) + .resizable() + .scaledToFill() + } else if palette.isResolved { + RadialGradient( + colors: [palette.controlTint, palette.controlTint.opacity(0)], + center: PublicGroupLook.glowCenter, + startRadius: 0, + endRadius: PublicGroupLook.glowRadius + ) + } + } + + /// On iOS 26 the system's edge effect fades the content; before it, the page's color does. + @ViewBuilder + private var pinnedFade: some View { + if #unavailable(iOS 26.0), hasPinned { + LinearGradient( + gradient: ChatFade.gradient(palette.background), + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea(edges: .bottom) + .allowsHitTesting(false) + } + } + + // MARK: - Identity - + + private var identity: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .bottom, spacing: 12) { + ContactAvatarView( + id: content.id, + displayName: content.title, + imageData: content.avatarImageData, + blurhash: content.avatarBlurhash, + size: Self.avatarSize + ) + .padding(avatarRing) + .background(palette.background, in: Circle()) + + accessory() + + Spacer(minLength: 0) + } + + Text(content.title) + .font(.appTitleLarge) + .foregroundStyle(palette.primaryText) + .multilineTextAlignment(.leading) + .padding(.top, PublicGroupLook.avatarToName) + .accessibilityIdentifier("profile-name") + + Text(PublicGroupCopy.summary(memberCount: content.memberCount)) + .font(.appTextSmall) + .foregroundStyle(palette.secondaryStyle) + .padding(.top, PublicGroupLook.nameToMeta) + + if let description = content.description, !description.isEmpty { + Text(description) + .font(.appTextMessage) + .foregroundStyle(palette.primaryText) + .multilineTextAlignment(.leading) + .padding(.top, PublicGroupLook.metaToBio) + // Its own animation, so the fade holds under Reduce Motion, which drops only + // the column's movement. + .transition(.opacity.animation(.easeOut(duration: 0.2))) + .accessibilityIdentifier("profile-bio") + } + + HStack(spacing: 12) { + actions() + } + .padding(.top, PublicGroupLook.bioToActions) + } + .padding(.horizontal, PublicGroupLook.sideMargin) + } + + private static var avatarSize: CGFloat { 72 } + /// The ring of page color around the avatar, which keeps it apart from the cover behind it. + private var avatarRing: CGFloat { + PublicGroupLook.avatarStroke ? PublicGroupLook.avatarStrokeWidth : 0 + } +} + +// MARK: - Buttons - + +/// The press every public-group control shares: a small scale, or a dim under Reduce Motion, with +/// the haptic on touch-down. +private struct PublicGroupPress: ViewModifier { + + let isPressed: Bool + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func body(content: Content) -> some View { + content + // Scoped to the press itself, so a state change that lands as the finger lifts snaps. + .animation(.spring(duration: 0.2, bounce: 0)) { content in + content + .scaleEffect(!reduceMotion && isPressed ? Self.scale : 1) + .opacity(reduceMotion && isPressed ? 0.7 : 1) + } + .onChange(of: isPressed) { _, isPressed in + if isPressed { Haptics.soft() } + } + } + + /// How far a pressed control shrinks. + private static let scale: CGFloat = 0.96 +} + +/// The outline pill under the bio: a rounded rectangle, a capsule at its default radius, stroked in +/// the palette's secondary color. +struct PublicGroupOutlineButtonStyle: ButtonStyle { + + private static var shape: RoundedRectangle { .rect(cornerRadius: PublicGroupLook.pillRadius) } + + @Environment(\.bleedPalette) private var palette + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.appTextMedium) + .foregroundStyle(palette.secondaryStyle) + .padding(.horizontal, 24) + .frame(minHeight: Metrics.buttonHeightThin) + .overlay { Self.shape.strokeBorder(palette.secondaryStyle, lineWidth: 1.5) } + .contentShape(Self.shape) + .modifier(PublicGroupPress(isPressed: configuration.isPressed)) + } +} + +/// The pinned Open Chat button: a full-width white capsule. +struct PublicGroupCapsuleButtonStyle: ButtonStyle { + + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.appTextMedium) + .foregroundStyle(isEnabled ? Color.textAction : Color.textMain.opacity(0.2)) + .frame(maxWidth: .infinity) + .frame(height: Self.height) + .background(isEnabled ? Color.action : Color.action.opacity(0.1), in: Capsule()) + .contentShape(Capsule()) + .modifier(PublicGroupPress(isPressed: configuration.isPressed)) + } + + /// The capsule's height, matching the amount screen's action button. + static var height: CGFloat { 54 } + + /// The side inset of the pinned bar, matching the amount screen's action button. + static var sideInset: CGFloat { 32 } +} + +/// The action that closes the stack, such as Leave Chat: a full-width capsule on the cards' +/// surface, as tall as Open Chat, its label in the palette's own control color. +struct PublicGroupClosingButtonStyle: ButtonStyle { + + @Environment(\.bleedPalette) private var palette + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.appTextMedium) + .foregroundStyle(labelStyle) + .tint(labelStyle) + .frame(maxWidth: .infinity) + .frame(height: PublicGroupCapsuleButtonStyle.height) + .background(palette.controlTint, in: Capsule()) + .contentShape(Capsule()) + .modifier(PublicGroupPress(isPressed: configuration.isPressed)) + } + + private var labelStyle: AnyShapeStyle { + PublicGroupLook.leaveLabelIsWhite ? AnyShapeStyle(palette.controlLabel) : palette.controlSecondaryStyle + } +} + +/// The press of a tappable row or face with no chrome of its own. +struct PublicGroupDimButtonStyle: ButtonStyle { + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .modifier(PublicGroupPress(isPressed: configuration.isPressed)) + } +} + +/// A hairline in the palette's divider color, one device pixel thick. +struct PublicGroupHairline: View { + + @Environment(\.bleedPalette) private var palette + @Environment(\.displayScale) private var displayScale + + var body: some View { + Rectangle() + .fill(palette.hairline) + .frame(height: 1 / displayScale) + } +} + +// MARK: - Preview - + +#if DEBUG +private extension PublicGroupProfileContent { + + static let blurhash = "LGF5]+Yk^6#M@-5c,1J5@[or[Q6." + + static func sample(cover: UIImage?, split: Bool, quiet: Bool) -> PublicGroupProfileContent { + let now = Date.now + let jeffy = PublicGroupToken(mint: .usdf, name: "Jeffy", imageURL: nil) + let names = ["Ted Livingston", "Brandon", "Ben", "Grampa", "Telzénar", "WanliGold", "Mia"] + let chatters = names.enumerated().map { index, name in + SampledChatter( + userID: UUID(), + profile: Profile(displayName: name, phone: Phone?.none, email: nil), + lastSentAt: quiet ? now.addingTimeInterval(-12 * 24 * 60 * 60) : now.addingTimeInterval(-Double(index + 1) * 120), + isCreator: index == 0 + ) + } + return PublicGroupProfileContent( + id: "preview", + title: "Flipcash Product Feedback", + memberCount: 22, + description: "Something look off? Not work? Crash? Post it here for the team to be aware.", + avatarImageData: nil, + avatarBlurhash: blurhash, + cover: cover, + requirements: split + ? [ + PublicGroupRequirementTile(purpose: .join, amount: "$50", token: jeffy), + PublicGroupRequirementTile(purpose: .chat, amount: "$100", token: jeffy), + ] + : [PublicGroupRequirementTile(purpose: .joinAndChat, amount: "$100", token: jeffy)], + namingTokens: [], + yourBalance: "$382.84 of Jeffy", + pulse: PublicGroupPulse(chatters: chatters, now: now), + host: chatters.first.map(GroupPortrait.init) + ) + } +} + +private struct PublicGroupProfilePreview: View { + + let hasCover: Bool + let split: Bool + let quiet: Bool + + var body: some View { + let image = BlurHashCache.shared.image(for: PublicGroupProfileContent.blurhash, width: 32, height: 32) + NavigationStack { + PublicGroupProfileView( + content: .sample(cover: hasCover ? image : nil, split: split, quiet: quiet), + hasPinned: true, + portraitData: { _ in nil }, + loadPortrait: { _ in }, + onSelectPerson: { _ in }, + onSeeChatters: {}, + onToken: { _ in }, + accessory: { EmptyView() }, + actions: { + Button("Share") {} + .buttonStyle(PublicGroupOutlineButtonStyle()) + }, + hasFooter: true, + footer: { + Button("Leave Chat") {} + .buttonStyle(PublicGroupClosingButtonStyle()) + }, + pinned: { + Button("Open Chat") {} + .buttonStyle(PublicGroupCapsuleButtonStyle()) + .padding(.horizontal, PublicGroupCapsuleButtonStyle.sideInset) + .padding(.top, 12) + .padding(.bottom, 8) + } + ) + .bleedPalette(key: "preview-\(hasCover)", image: image, settings: PublicGroupLook.settings) + } + .preferredColorScheme(.dark) + } +} + +#Preview("Cover, busy") { + PublicGroupProfilePreview(hasCover: true, split: false, quiet: false) +} + +#Preview("Avatar colored, split, quiet") { + PublicGroupProfilePreview(hasCover: false, split: true, quiet: true) +} +#endif + +// MARK: - iOS 18 bar - + +/// Before iOS 26 the app's navigation bar is an opaque `backgroundMain` slab, which would sit over +/// the top of the cover. Here the bar has no background of its own; once the cover's bottom edge +/// has scrolled up to the bar's, a strip of the page's color fills in behind it. All the melt +/// shows there is that color, so the swap can't be seen. iOS 26's glass bar needs neither. +private struct LegacyCoverBar: ViewModifier { + + let color: Color + /// How far the content scrolls before the cover's bottom edge reaches the bar's. + let coverScroll: CGFloat + + @State private var isPastCover = false + + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content + } else { + content + .onScrollGeometryChange(for: Bool.self) { geometry in + geometry.contentOffset.y + geometry.contentInsets.top >= coverScroll + } action: { _, isPast in + isPastCover = isPast + } + .overlay(alignment: .top) { + if isPastCover { + // Sits on the safe area's top edge and grows up under the bars. + color + .ignoresSafeArea(edges: .top) + .frame(height: 0) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + .toolbarBackground(.hidden, for: .navigationBar) + } + } +} + +// MARK: - Back indicator - + +/// Puts the app's back arrow on the screen's own bar appearance. `toolbarColorScheme` gives the +/// screen a navigation item appearance of its own, which drops the arrow `AppDelegate` sets on the +/// bar's shared one, so the system chevron would show; the back button and its swipe stay the +/// system's. +private struct AppBackIndicator: UIViewControllerRepresentable { + + func makeUIViewController(context: Context) -> Proxy { Proxy() } + + func updateUIViewController(_ proxy: Proxy, context: Context) { + proxy.apply() + } + + final class Proxy: UIViewController { + + /// The same glyph `AppDelegate.backIndicator()` draws for every other bar. + private static let arrow: UIImage = { + let size = CGSize(width: 22, height: 22) + let arrow = UIImage.icon(.arrowLeft) + return UIGraphicsImageRenderer(size: size).image { _ in + arrow.draw(in: CGRect(origin: .zero, size: size)) + } + .withRenderingMode(.alwaysTemplate) + }() + + override func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + apply() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + apply() + } + + override func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) + apply() + } + + func apply() { + guard let item = pushedController?.navigationItem else { return } + item.standardAppearance = Self.withArrow(item.standardAppearance) + item.scrollEdgeAppearance = Self.withArrow(item.scrollEdgeAppearance) + item.compactAppearance = Self.withArrow(item.compactAppearance) + item.compactScrollEdgeAppearance = Self.withArrow(item.compactScrollEdgeAppearance) + } + + /// The controller the navigation stack pushed, whose item the bar draws. + private var pushedController: UIViewController? { + var candidate: UIViewController? = self + while let current = candidate { + if current.parent is UINavigationController { return current } + candidate = current.parent + } + return nil + } + + private static func withArrow(_ appearance: UINavigationBarAppearance?) -> UINavigationBarAppearance? { + guard let appearance, appearance.backIndicatorImage !== arrow else { return appearance } + let copy = appearance.copy() + copy.setBackIndicatorImage(arrow, transitionMaskImage: arrow) + return copy + } + } +} diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index f6fd8edcf..19bd0bec7 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -216,7 +216,7 @@ private struct UserProfileContent: View { } } .dialog(item: $dialogItem) - .sheet(isPresented: $isPickingMuteDuration) { + .tray(isPresented: $isPickingMuteDuration) { if let dmID { MuteChatSheet(conversationID: dmID, isPresented: $isPickingMuteDuration) } @@ -304,7 +304,7 @@ private struct UserProfileContent: View { } private var shareButton: some View { - ProfileActionCircle(image: Image.asset(.shareOS)) { + ProfileActionCircle(image: Image.icon(.shareOs)) { isShowingShare = true } .accessibilityLabel("Share profile") diff --git a/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift b/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift index acdae54d4..267b60231 100644 --- a/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift @@ -27,6 +27,12 @@ struct SettingsAdvancedBetaFeaturesScreen: View { @State private var unlinkAlertTitle: String? @State private var unlinkAlertMessage: String? @State private var isShowingUnlinkAlert: Bool = false + #if DEBUG + /// The tray the showcase has up over this screen. + @State private var showcaseTray: TrayKind? + /// Whether the swipe showcase is up over this screen. + @State private var isShowingSwipeShowcase = false + #endif private let publicOptions = BetaFlags.Option.allCases.filter { $0.availability == .publicBeta } private let developerOptions = BetaFlags.Option.allCases.filter { $0.availability == .developer } @@ -47,6 +53,17 @@ struct SettingsAdvancedBetaFeaturesScreen: View { } } else { LazyTable(spacing: 0) { + #if DEBUG + SettingsSectionHeader("Tray Showcase") + ForEach(TrayKind.allCases) { kind in + unlinkRow(title: kind.name, isDisabled: false) { showcaseTray = kind } + .accessibilityIdentifier("tray-showcase-\(kind.rawValue)") + } + SettingsSectionHeader("Swipe Showcase") + unlinkRow(title: "Swipe to Send", isDisabled: false) { isShowingSwipeShowcase = true } + .accessibilityIdentifier("swipe-showcase") + #endif + ForEach(publicOptions) { option in BetaFlagToggleRow(option: option, isOn: betaFlags.bindingFor(option: option)) } @@ -80,6 +97,17 @@ struct SettingsAdvancedBetaFeaturesScreen: View { } .navigationTitle("Beta Features") .toolbarTitleDisplayMode(.inline) + #if DEBUG + // Each showcase tray comes up over this screen, as it would over any other. + .tray(item: $showcaseTray, isDismissable: showcaseTray?.isDismissable ?? true) { kind in + kind.tray + } + // The swipe showcase comes up in a sheet, as the send screen does. + .sheet(isPresented: $isShowingSwipeShowcase) { + SwipeShowcaseHost() + .presentationDragIndicator(.visible) + } + #endif .alert( "Unlink Email?", isPresented: $isConfirmingUnlinkEmail diff --git a/Flipcash/Core/Screens/Settings/SettingsAppSettingsScreen.swift b/Flipcash/Core/Screens/Settings/SettingsAppSettingsScreen.swift index c1d4e9484..0a654325a 100644 --- a/Flipcash/Core/Screens/Settings/SettingsAppSettingsScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsAppSettingsScreen.swift @@ -20,7 +20,7 @@ struct SettingsAppSettingsScreen: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { Row(insets: insets) { - Image.asset(.camera).frame(minWidth: 45) + Image.icon(.camera1).iconSize(24).frame(minWidth: 45) Toggle("Auto Start Camera", isOn: cameraAutoStartDisabledBinding()) .multilineTextAlignment(.leading) .truncationMode(.tail) diff --git a/Flipcash/Core/Screens/Settings/SettingsRow.swift b/Flipcash/Core/Screens/Settings/SettingsRow.swift index 7b7816c0d..7c9bea6ce 100644 --- a/Flipcash/Core/Screens/Settings/SettingsRow.swift +++ b/Flipcash/Core/Screens/Settings/SettingsRow.swift @@ -12,12 +12,12 @@ import FlipcashUI struct SettingsRow: View { let image: Image - let title: String + let title: LocalizedStringKey let badge: Badge? let insets: EdgeInsets let action: VoidAction - init(image: Image, title: String, badge: Badge? = nil, insets: EdgeInsets, action: @escaping VoidAction) { + init(image: Image, title: LocalizedStringKey, badge: Badge? = nil, insets: EdgeInsets, action: @escaping VoidAction) { self.image = image self.title = title self.badge = badge @@ -25,28 +25,39 @@ struct SettingsRow: View { self.action = action } - init(asset: Asset, title: String, badge: Badge? = nil, insets: EdgeInsets, action: @escaping VoidAction) { - self.init(image: Image.asset(asset), title: title, badge: badge, insets: insets, action: action) - } - - init(systemImage: String, title: String, badge: Badge? = nil, insets: EdgeInsets, action: @escaping VoidAction) { - self.init(image: Image(systemName: systemImage), title: title, badge: badge, insets: insets, action: action) + init(icon: Icon, title: LocalizedStringKey, badge: Badge? = nil, insets: EdgeInsets, action: @escaping VoidAction) { + self.init(image: Image.icon(icon), title: title, badge: badge, insets: insets, action: action) } var body: some View { - Row(insets: insets) { - image.frame(minWidth: 45) - Text(title) - .multilineTextAlignment(.leading) - .truncationMode(.tail) - Spacer() - if let badge { - badge + ListRow(title, tone: tone, showsDivider: showsDivider, action: action) { + ListRowIcon(image) + } trailing: { + HStack(spacing: 8) { + if let badge { + badge + } + if showsChevron { + ListRowChevron() + } } - } action: { - action() } } + + // Rows v2 exploration: set per call site without widening every initializer. + private var tone: ListRowTone { isDestructive ? .destructive : .standard } + var showsDivider = true + var showsChevron = true + var isDestructive = false + + /// Hides the divider, for the last row of a section. + func lastInSection() -> Self { var copy = self; copy.showsDivider = false; return copy } + + /// Drops the chevron, for a row that acts in place instead of pushing. + func actsInPlace() -> Self { var copy = self; copy.showsChevron = false; return copy } + + /// Draws the title and icon in the error colour, for a destructive action. + func destructive() -> Self { var copy = self; copy.isDestructive = true; return copy } } extension Badge { diff --git a/Flipcash/Core/Screens/Settings/SettingsScreen.swift b/Flipcash/Core/Screens/Settings/SettingsScreen.swift index 7978bdc69..2cf3c7a93 100644 --- a/Flipcash/Core/Screens/Settings/SettingsScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsScreen.swift @@ -80,7 +80,7 @@ struct SettingsScreen: View { VStack(alignment: .leading, spacing: 0) { SettingsSectionHeader("Security") - SettingsRow(systemImage: "key.horizontal", title: "Access Key", insets: insets) { + SettingsRow(icon: .key1, title: "Access Key", insets: insets) { dialogItem = .alert( title: "View Your Access Key?", subtitle: "Your Access Key will grant access to your Flipcash account. Keep it private and safe" @@ -91,6 +91,7 @@ struct SettingsScreen: View { DialogAction.cancel() } } + .lastInSection() } } @@ -98,13 +99,14 @@ struct SettingsScreen: View { VStack(alignment: .leading, spacing: 0) { SettingsSectionHeader("Privacy") - SettingsRow(systemImage: "nosign", title: "Blocked", insets: insets) { + SettingsRow(icon: .circleBanSign, title: "Blocked", insets: insets) { router.push(.blockedUsers) } - SettingsRow(systemImage: "globe", title: "Trusted Websites", insets: insets) { + SettingsRow(icon: .globe, title: "Trusted Websites", insets: insets) { router.push(.trustedWebsites) } + .lastInSection() .accessibilityIdentifier("settings-trusted-websites-row") } } @@ -113,18 +115,20 @@ struct SettingsScreen: View { VStack(alignment: .leading, spacing: 0) { SettingsSectionHeader("Advanced") - SettingsRow(systemImage: "flask", title: "Beta Features", insets: insets) { + SettingsRow(icon: .bug, title: "Beta Features", insets: insets) { router.push(.settingsAdvancedBetaFeatures) } - SettingsRow(systemImage: "doc.text", title: "Application Logs", insets: insets) { + let logs = SettingsRow(icon: .fileText, title: "Application Logs", insets: insets) { router.push(.settingsApplicationLogs) } + if betaFlags.canSwitchAccounts { logs } else { logs.lastInSection() } if betaFlags.canSwitchAccounts { - SettingsRow(asset: .switchAccounts, title: "Switch Accounts", badge: .beta, insets: insets) { + SettingsRow(icon: .group1, title: "Switch Accounts", badge: .beta, insets: insets) { router.push(.settingsAccountSelection) } + .lastInSection() .accessibilityIdentifier("account-switch-accounts-row") } } @@ -135,13 +139,13 @@ struct SettingsScreen: View { SettingsSectionHeader("Account") if betaFlags.canViewAccountInfo { - SettingsRow(systemImage: "person.crop.circle", title: "Account Info", badge: .beta, insets: insets) { + SettingsRow(icon: .circlePerson, title: "Account Info", badge: .beta, insets: insets) { router.push(.accountInfo) } .accessibilityIdentifier("settings-account-info-row") } - SettingsRow(asset: .logout, title: "Log Out", insets: insets) { + SettingsRow(icon: .arrowBoxLeft, title: "Log Out", insets: insets) { dialogItem = .alert( title: "Are You Sure You Want To Log Out?", subtitle: "You can get into this account using your Access Key" @@ -152,8 +156,8 @@ struct SettingsScreen: View { DialogAction.cancel() } } - - SettingsRow(asset: .delete, title: "Delete Account", insets: insets) { + .actsInPlace() + SettingsRow(icon: .trashCan, title: "Delete Account", insets: insets) { dialogItem = .alert( title: "Permanently Delete Account?", subtitle: "This will permanently delete your Flipcash account" @@ -164,6 +168,9 @@ struct SettingsScreen: View { DialogAction.cancel() } } + .actsInPlace() + .destructive() + .lastInSection() } } diff --git a/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift b/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift index 3cd8ecce2..543ac44ff 100644 --- a/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift +++ b/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift @@ -9,22 +9,13 @@ import FlipcashUI /// The small grey heading that opens a group of rows on a settings screen. struct SettingsSectionHeader: View { - let title: String + let title: LocalizedStringKey - init(_ title: String) { + init(_ title: LocalizedStringKey) { self.title = title } var body: some View { - HStack { - Text(title) - .textCase(.uppercase) - .font(.appTextHeading) - .foregroundStyle(.textSecondary) - Spacer() - } - .padding(.horizontal, 20) - .padding(.top, 20) - .padding(.bottom, 8) + ListSectionTitle(title) } } diff --git a/Flipcash/Core/Screens/Settings/SwipeFlowShowcase.swift b/Flipcash/Core/Screens/Settings/SwipeFlowShowcase.swift new file mode 100644 index 000000000..97f5a3a70 --- /dev/null +++ b/Flipcash/Core/Screens/Settings/SwipeFlowShowcase.swift @@ -0,0 +1,128 @@ +// +// SwipeFlowShowcase.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if DEBUG +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// The swipe showcase's root: the control on its own, and, when signed in, the swipe inside the +/// real amount screen. Opened from Beta Features and by `--swipe-sandbox`. +/// +/// The amount flow needs the signed-in `Session` and `RatesController` that `EnterAmountView` +/// reads, so without them (the sandbox) only the control shows. +struct SwipeShowcaseHost: View { + + @Environment(Session.self) private var session: Session? + @Environment(RatesController.self) private var ratesController: RatesController? + + @State private var page: Page = .looks + + private var isFlowAvailable: Bool { + session != nil && ratesController != nil + } + + var body: some View { + Background(color: .backgroundMain) { + VStack(spacing: 0) { + if isFlowAvailable { + Picker("Showcase", selection: $page) { + ForEach(Page.allCases) { page in + Text(page.title).tag(page) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 20) + .padding(.top, 20) + } + + switch isFlowAvailable ? page : .looks { + case .looks: + SwipeShowcaseScreen() + case .amountFlow: + SwipeFlowShowcase() + } + } + } + .preferredColorScheme(.dark) + } +} + +extension SwipeShowcaseHost { + + /// The showcase's two pages. + private enum Page: String, CaseIterable, Identifiable { + case looks + case amountFlow + + var id: String { rawValue } + + var title: String { + switch self { + case .looks: "Looks" + case .amountFlow: "Amount flow" + } + } + } +} + +// MARK: - Amount flow - + +/// The real `EnterAmountView` laid out as the send screen lays it out, with a swipe whose action +/// is a local sleep that succeeds or fails. Nothing is sent: no view model, session call or network. +struct SwipeFlowShowcase: View { + + @Environment(Session.self) private var session + + @State private var enteredAmount = "" + @State private var outcome: SwipeShowcaseOutcome = .succeeds + + /// Enabled once there's an amount, standing in for the send screen's `canSend`. + private func isActionEnabled(_ entered: String) -> Bool { + guard outcome != .disabled else { return false } + guard let value = AmountValidator().validate(entered) else { return false } + return value > 0 + } + + var body: some View { + let available = session.totalBalance + + VStack(spacing: 0) { + controls + .padding(.horizontal, 20) + .padding(.top, 16) + + EnterAmountView( + mode: .currency, + enteredAmount: $enteredAmount, + subtitle: .balanceWithLimit(available), + actionEnabled: { isActionEnabled($0) }, + header: AnyView(EnterAmountHeader( + enteredAmount: $enteredAmount, + hint: .available(available) + )) + ) { + SwipeControl(text: "Swipe to Send", action: outcome.action) + } + .foregroundStyle(.textMain) + .padding(20) + } + .ignoresSafeArea(.keyboard) + } + + // MARK: - Controls - + + private var controls: some View { + Picker("Outcome", selection: $outcome) { + ForEach(SwipeShowcaseOutcome.allCases) { outcome in + Text(outcome.title).tag(outcome) + } + } + .pickerStyle(.segmented) + } +} +#endif diff --git a/Flipcash/Supporting Files/Assets.xcassets/Colors/background.colorset/Contents.json b/Flipcash/Supporting Files/Assets.xcassets/Colors/background.colorset/Contents.json index 05ec09822..9d1750328 100644 --- a/Flipcash/Supporting Files/Assets.xcassets/Colors/background.colorset/Contents.json +++ b/Flipcash/Supporting Files/Assets.xcassets/Colors/background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "display-p3", "components" : { "alpha" : "1.000", - "blue" : "0x1A", - "green" : "0x19", - "red" : "0x19" + "blue" : "0x12", + "green" : "0x12", + "red" : "0x12" } }, "idiom" : "universal" diff --git a/Flipcash/UI/EnterAmountHeader.swift b/Flipcash/UI/EnterAmountHeader.swift index 319e33e5f..7cdf09c22 100644 --- a/Flipcash/UI/EnterAmountHeader.swift +++ b/Flipcash/UI/EnterAmountHeader.swift @@ -57,7 +57,7 @@ struct EnterAmountHeader: View { private var hintFont: Font { switch hint { - case .available, .caption: .appTextMedium + case .available, .caption: .default(size: 16) // Amount entry v2: the hint reads regular, like the Figma header case .description: .appTextSmall } } diff --git a/Flipcash/UI/EnterAmountView.swift b/Flipcash/UI/EnterAmountView.swift index 2b78367fc..e6b227de4 100644 --- a/Flipcash/UI/EnterAmountView.swift +++ b/Flipcash/UI/EnterAmountView.swift @@ -31,6 +31,9 @@ public struct EnterAmountView: View { /// sits at the top rather than centered in the upper area. private let header: AnyView? + /// The hosted swipe's travel, which dims the rest of the screen. + @State private var swipeProgress: CGFloat = 0 + // MARK: - Calculator - private var calculator: EnterAmountCalculator { @@ -98,10 +101,31 @@ public struct EnterAmountView: View { return !actionEnabled(enteredAmount) } + /// The action takes taps only while it isn't already running or showing its result. + private var isActionIdle: Bool { + if case .normal = actionState { return true } + return false + } + private var subtitleColor: Color { isExceedingLimit ? .textError : .textSecondary } + /// The opacity everything but the swipe takes at the hosted swipe's current travel. + private var dimOpacity: Double { + guard actionOverride != nil else { return 1 } + let travel = min(max(Double(swipeProgress), 0), 1) + return 1 - (1 - SwipeDim.maxDim) * travel + } + + /// An error subtitle stays bright while the rest dims. + private var subtitleOpacity: Double { + switch subtitle { + case .error: 1 + case .singleTransactionLimit, .balanceWithLimit, .hidden: dimOpacity + } + } + // MARK: - Body - public var body: some View { @@ -128,30 +152,33 @@ public struct EnterAmountView: View { .foregroundStyle(.textMain) } - switch subtitle { - case .singleTransactionLimit: - if let limit = calculator.maxTransactionAmount { - Text("Enter up to \(limit.formatted())") + Group { + switch subtitle { + case .singleTransactionLimit: + if let limit = calculator.maxTransactionAmount { + Text("Enter up to \(limit.formatted())") + .fixedSize() + .foregroundStyle(subtitleColor) + .font(.appTextMedium) + } + + case .balanceWithLimit(let maxBalance): + Text("Enter up to \(calculator.maxEnterAmount(maxBalance: maxBalance).formatted())") .fixedSize() .foregroundStyle(subtitleColor) .font(.appTextMedium) - } - case .balanceWithLimit(let maxBalance): - Text("Enter up to \(calculator.maxEnterAmount(maxBalance: maxBalance).formatted())") - .fixedSize() - .foregroundStyle(subtitleColor) - .font(.appTextMedium) - - case .error(let text): - Text(text) - .fixedSize() - .foregroundStyle(.textError) - .font(.appTextMedium) + case .error(let text): + Text(text) + .fixedSize() + .foregroundStyle(.textError) + .font(.appTextMedium) - case .hidden: - EmptyView() + case .hidden: + EmptyView() + } } + .opacity(subtitleOpacity) } .frame(maxWidth: .infinity) } @@ -160,6 +187,7 @@ public struct EnterAmountView: View { if let accessory { accessory + .opacity(dimOpacity) // Capped but shrinkable so the keypad/button aren't pushed off // on small (SE-class) screens. Spacer() @@ -175,22 +203,55 @@ public struct EnterAmountView: View { ) ) .padding([.leading, .trailing], -20) - + .opacity(dimOpacity) + // The amount can't change mid-send, so the keypad locks whenever the thumb is off rest. + .allowsHitTesting(swipeProgress == 0) + if let actionOverride { actionOverride .disabled(!actionEnabled(enteredAmount)) - .padding(.top, 10) + // Sits in the Next button's slot: same side and bottom insets as the button below. + .padding(.top, SwipeDim.keypadToSwipe) + .modifier(ActionSlotInsets()) } else { - CodeButton( - state: actionState, - style: mode.buttonStyle, - title: actionTitle ?? mode.actionName, - disabled: !actionEnabled(enteredAmount), - action: action - ) - .padding(.top, 10) + Button(action: action) { + ButtonStateLabel(actionTitle ?? mode.actionName, state: actionState) + } + .buttonStyle(AmountActionButtonStyle()) + .disabled(!actionEnabled(enteredAmount) || !isActionIdle) + // Amount entry v2: 32 from the screen's sides and bottom (the screens already pad 20), + // 20 below the keypad. + .padding(.top, 20) + .modifier(ActionSlotInsets()) } } + // The control animates the value on its own snap-back and sends every frame, so it lands unanimated. + .onPreferenceChange(SwipeControlProgressKey.self) { progress in + withTransaction(Transaction(animation: nil)) { swipeProgress = progress } + } + } +} + +// MARK: - Swipe dim - + +/// How the amount screen lays out and dims around a hosted swipe; the entered amount never dims. +private enum SwipeDim { + /// The opacity everything but the amount and the swipe falls to at full travel. + static let maxDim: Double = 0.1 + /// The gap between the keypad and the hosted swipe. + static let keypadToSwipe: CGFloat = 20 +} + +/// The side and bottom insets of the action slot, shared by the Next button and a hosted swipe so +/// the two can't drift. +private struct ActionSlotInsets: ViewModifier { + func body(content: Content) -> some View { + // 32 from the screen's bottom edge on any device: the screens pad 20 inside the safe area, + // so the slot reads the inset from the window, never touching it in its own geometry. + let bottomInset = UIApplication.shared.currentKeyWindow?.safeAreaInsets.bottom ?? 0 + content + .padding(.horizontal, 32 - 20) + .padding(.bottom, 32 - 20 - bottomInset) } } @@ -272,3 +333,26 @@ extension EnterAmountView { actionEnabled: { _ in return true } ) {} } + + +// MARK: - Action button - + +/// The amount screen's primary action: a full-width capsule in the action colour, faded when disabled. +private struct AmountActionButtonStyle: ButtonStyle { + + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.appTextMedium) + .foregroundStyle(isEnabled ? Color.textAction : Color.textMain.opacity(0.2)) + .frame(maxWidth: .infinity) + .frame(height: 54) + .background { + RoundedRectangle(cornerRadius: 27, style: .continuous) + .fill(isEnabled ? Color.action : Color.action.opacity(0.1)) + } + .opacity(configuration.isPressed ? 0.7 : 1.0) + .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) + } +} diff --git a/Flipcash/UI/TokenSelectorButton.swift b/Flipcash/UI/TokenSelectorButton.swift index f23b4d869..d07a1fa30 100644 --- a/Flipcash/UI/TokenSelectorButton.swift +++ b/Flipcash/UI/TokenSelectorButton.swift @@ -23,8 +23,8 @@ struct TokenSelectorButton: View { amount: nil ) - Image.system(.chevronDown) - .font(.default(size: 12, weight: .bold)) + Image.icon(.chevronDownMedium) + .iconSize(16) .foregroundStyle(.textMain) } } diff --git a/Flipcash/UI/View+Dialog.swift b/Flipcash/UI/View+Dialog.swift index ddc69b694..b4f05ea6c 100644 --- a/Flipcash/UI/View+Dialog.swift +++ b/Flipcash/UI/View+Dialog.swift @@ -7,33 +7,26 @@ import SwiftUI import FlipcashUI extension View { - /// Presents a `DialogItem`, reporting an analytics event for tracked items. + /// Presents a `DialogItem` as a tray, reporting an analytics event for tracked items. func dialog(item: Binding) -> some View { - modifier(DialogItemModifier(item: item)) + modifier(DialogTrayModifier(item: item)) } } -private struct DialogItemModifier: ViewModifier { +/// Presents a `DialogItem` as a tray: its title, subtitle, style, dismissability, actions, +/// analytics and `onDismiss`, with an icon for the error and success styles. +private struct DialogTrayModifier: ViewModifier { let item: Binding @Environment(AppRouter.self) private var router: AppRouter? - /// Captured while the dialog is up so its `onDismiss` still fires when the - /// scrim tap clears the binding before `onDismiss` runs. + /// Captured while the tray is up so its `onDismiss` still fires when a drag clears the binding. @State private var pendingDismiss: (() -> Void)? func body(content: Content) -> some View { content - .sheet(item: item, onDismiss: runPendingDismiss) { presented in - PartialSheet(background: presented.style.backgroundColor, canDismiss: presented.dismissable) { - Dialog( - style: presented.style, - title: presented.title, - subtitle: presented.subtitle, - checkbox: presented.checkbox, - dismiss: dismiss, - actions: presented.actions - ) + .tray(item: item, isDismissable: item.wrappedValue?.dismissable ?? true, onDismiss: runPendingDismiss) { presented in + DialogTray(item: presented, dismiss: dismiss) .onAppear { pendingDismiss = presented.onDismiss guard presented.tracked, @@ -46,7 +39,6 @@ private struct DialogItemModifier: ViewModifier { callSite: nil ) } - } } } @@ -59,3 +51,86 @@ private struct DialogItemModifier: ViewModifier { pendingDismiss = nil } } + +/// A `DialogItem` drawn as a single-step tray, its checkbox as the tray's content. +private struct DialogTray: View { + + let item: DialogItem + let dismiss: () -> Void + + @State private var isChecked = false + + var body: some View { + if let checkbox = item.checkbox { + Tray(title: item.title ?? "", message: item.subtitle, icon: icon, style: style, actions: actions) { + checkboxRow(checkbox) + } + } else { + Tray(title: item.title ?? "", message: item.subtitle, icon: icon, style: style, actions: actions) + } + } + + private var style: TrayStyle { + switch item.style { + case .standard: .standard + case .success: .success + case .destructive: .error + } + } + + private var icon: TrayIcon? { + switch item.style { + case .standard: nil + case .success: .central(.circleCheck) + case .destructive: .destructive + } + } + + /// The dialog's actions as tray actions, each dismissing before it runs as the dialog's do. + /// A second action that would share the first one's place takes the secondary's. + private var actions: [TrayAction] { + var taken: Set = [] + return item.actions.map { action in + // A lone destructive action is the red dialog's OK, not a warning. + var role = item.actions.count == 1 ? .primary : Self.role(for: action.kind) + let slot = role == .secondary ? 0 : 1 + if taken.contains(slot) { + role = slot == 0 ? .primary : .secondary + } + taken.insert(role == .secondary ? 0 : 1) + return TrayAction(action.title, role: role) { + dismiss() + action.perform(isChecked: isChecked) + } + } + } + + private static func role(for kind: DialogAction.Kind) -> TrayAction.Role { + switch kind { + case .standard: .primary + case .destructive: .destructive + case .subtle, .outline: .secondary + } + } + + private func checkboxRow(_ checkbox: DialogCheckbox) -> some View { + Button { + isChecked.toggle() + } label: { + HStack(spacing: 10) { + Image(systemName: isChecked ? "checkmark.square.fill" : "square") + .font(.system(size: 20)) + Text(checkbox.label) + .font(.default(size: 14, weight: .medium)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 8) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(Color.textMain) + .accessibilityAddTraits(isChecked ? .isSelected : []) + .accessibilityIdentifier("dialog-checkbox") + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Formatters/DateFormatter.swift b/FlipcashCore/Sources/FlipcashCore/Formatters/DateFormatter.swift index 105a09ee3..15ba62567 100644 --- a/FlipcashCore/Sources/FlipcashCore/Formatters/DateFormatter.swift +++ b/FlipcashCore/Sources/FlipcashCore/Formatters/DateFormatter.swift @@ -88,6 +88,21 @@ extension Date { return "\(day) at \(time)" } + /// A chat's last activity for the chat list: the time today, the relative day for yesterday, a + /// short weekday within the past week, and a short month and day before that. + public func formattedChatListTime() -> String { + let calendar = Calendar.current + if calendar.isDateInToday(self) { + return formatted(date: .omitted, time: .shortened) + } + if calendar.isDateInYesterday(self) { + return DateFormatter.relativeDay.string(from: self) + } + return self > Date.weekAgo() + ? formatted(.dateTime.weekday(.abbreviated)) + : formatted(.dateTime.month(.abbreviated).day()) + } + public func formattedRelatively(useTimeForToday: Bool = false) -> String { let calendar = Calendar.current let weekAgo = Date.weekAgo() diff --git a/FlipcashCore/Tests/FlipcashCoreTests/FormattedChatListTimeTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/FormattedChatListTimeTests.swift new file mode 100644 index 000000000..33a3b57a0 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/FormattedChatListTimeTests.swift @@ -0,0 +1,45 @@ +// +// FormattedChatListTimeTests.swift +// FlipcashCoreTests +// + +import Foundation +import Testing +import FlipcashCore + +/// Dates are built relative to `.now` at noon to keep them clear of midnight boundaries. The +/// "Yesterday" literal assumes the en_US test environment, as `FormattedRelativelyTests` does. +@Suite("Date.formattedChatListTime") +struct FormattedChatListTimeTests { + + private let calendar = Calendar.current + + private func daysAgo(_ days: Int) -> Date { + let noonToday = calendar.date(bySettingHour: 12, minute: 0, second: 0, of: .now)! + return calendar.date(byAdding: .day, value: -days, to: noonToday)! + } + + @Test("Today renders the time of day") + func today() { + #expect(daysAgo(0).formattedChatListTime().contains("12:00")) + } + + @Test("Yesterday renders the localized relative day") + func yesterday() { + let date = daysAgo(1) + #expect(date.formattedChatListTime() == DateFormatter.relativeDay.string(from: date)) + #expect(date.formattedChatListTime() == "Yesterday") + } + + @Test("Dates two through six days ago render the short weekday", arguments: 2...6) + func weekdayWithinTheWeek(daysAgo offset: Int) { + let date = daysAgo(offset) + let weekday = calendar.shortWeekdaySymbols[calendar.component(.weekday, from: date) - 1] + #expect(date.formattedChatListTime() == weekday) + } + + @Test("Dates older than a week render a short month and day") + func olderThanAWeek() { + #expect(daysAgo(30).formattedChatListTime().wholeMatch(of: /[A-Za-z]{3} \d{1,2}/) != nil) + } +} diff --git a/FlipcashTests/Chat/ChatBubbleViewTests.swift b/FlipcashTests/Chat/ChatBubbleViewTests.swift index 35ba4a377..3989501dc 100644 --- a/FlipcashTests/Chat/ChatBubbleViewTests.swift +++ b/FlipcashTests/Chat/ChatBubbleViewTests.swift @@ -15,8 +15,8 @@ import FlipcashCore @Suite("Bubble corner grouping") struct ChatBubbleViewCornerTests { - private let base = BubbleBackgroundView.baseRadius // 12 - private let grouped = BubbleBackgroundView.groupedRadius // 4 + private let base = BubbleBackgroundView.baseRadius + private let grouped = BubbleBackgroundView.groupedRadius @Test("A standalone bubble uses the base radius on all four corners") func standalone_allBase() { @@ -49,7 +49,14 @@ struct ChatBubbleViewCornerTests { return bubble } - @Test("The author run alone does not flatten the corner; only the bubble run does") + /// The mask `bubble` would have if its background were cut to `radii`, in the same frame, so a + /// run's shape is asserted whatever the joined corner's radius is. + private func mask(of bubble: ChatBubbleView, radii: RectangleCornerRadii) -> CGPath? { + guard let background = bubble.subviews.lazy.compactMap({ $0 as? BubbleBackgroundView }).first else { return nil } + return UIBezierPath(cgPath: BubbleBackgroundView.path(radii: radii, in: background.bounds)).cgPath + } + + @Test("The author run alone keeps the standalone top corner; only the bubble run takes the joined one") func authorRunAloneLeavesTheCornerUntouched() { let authorRunOnly = laidOutBubble( ChatMessage(id: "1", text: "hi", sender: .me, isContinuationFromPrevious: true, joinsBubbleAbove: false) @@ -62,10 +69,11 @@ struct ChatBubbleViewCornerTests { let bubbleRun = laidOutBubble( ChatMessage(id: "3", text: "hi", sender: .me, joinsBubbleAbove: true) ) - #expect(bubbleRun.maskingPath?.cgPath != neitherRun.maskingPath?.cgPath) + let joined = BubbleBackgroundView.radii(isFromSelf: true, groupedAbove: true, groupedBelow: false) + #expect(bubbleRun.maskingPath?.cgPath == mask(of: bubbleRun, radii: joined)) } - @Test("The author run alone does not flatten the bottom corner; only the bubble run does") + @Test("The author run alone keeps the standalone bottom corner; only the bubble run takes the joined one") func authorRunAloneLeavesTheBottomCornerUntouched() { let authorRunOnly = laidOutBubble( ChatMessage(id: "1", text: "hi", sender: .me, isContinuedByNext: true, joinsBubbleBelow: false) @@ -78,7 +86,8 @@ struct ChatBubbleViewCornerTests { let bubbleRun = laidOutBubble( ChatMessage(id: "3", text: "hi", sender: .me, joinsBubbleBelow: true) ) - #expect(bubbleRun.maskingPath?.cgPath != neitherRun.maskingPath?.cgPath) + let joined = BubbleBackgroundView.radii(isFromSelf: true, groupedAbove: false, groupedBelow: true) + #expect(bubbleRun.maskingPath?.cgPath == mask(of: bubbleRun, radii: joined)) } @Test("A middle bubble in a self run flattens both inner (trailing) corners") @@ -109,15 +118,17 @@ struct ChatMessageCellAlignmentTests { @Test("A self message hugs the trailing edge") func selfMessage_hugsTrailing() { let (cell, bubble) = laidOutCell(sender: .me) - #expect(abs(bubble.maxX - (cell.contentView.bounds.width - 12)) < 0.5) - #expect(bubble.minX > 12) // does not span the full width + let inset = ChatColumnCell.rowInset + #expect(abs(bubble.maxX - (cell.contentView.bounds.width - inset)) < 0.5) + #expect(bubble.minX > inset) // does not span the full width } @Test("An other message hugs the leading edge") func otherMessage_hugsLeading() { let (cell, bubble) = laidOutCell(sender: .other) - #expect(abs(bubble.minX - 12) < 0.5) - #expect(bubble.maxX < cell.contentView.bounds.width - 12) + let inset = ChatColumnCell.rowInset + #expect(abs(bubble.minX - inset) < 0.5) + #expect(bubble.maxX < cell.contentView.bounds.width - inset) } } @@ -191,7 +202,7 @@ struct ChatBubbleDeletedTests { #expect(!marker.isHidden) #expect(abs(marker.frame.maxX - (bubble.bounds.width - EditedMarker.trailingInset)) < 0.5) - #expect(abs(marker.frame.maxY - (bubble.bounds.height - 9)) < 0.5) + #expect(abs(marker.frame.maxY - (bubble.bounds.height - ChatStyle.bubblePaddingY)) < 0.5) } @Test("A last line with room keeps the marker on it, widening the bubble") @@ -291,11 +302,11 @@ struct ChatBubbleViewBareTests { #expect(body(view, "👍")?.frame.minX == 0) } - @Test("An ordinary row keeps the body size and the 12pt inset") + @Test("An ordinary row keeps the body size and the bubble's text inset") func ordinaryKeepsTheBodyMetrics() { let view = bubble(ChatMessage(id: "1", text: "hi", sender: .me)) - #expect(body(view, "hi")?.font.pointSize == 16) - #expect(body(view, "hi")?.frame.minX == 12) + #expect(body(view, "hi")?.font.pointSize == ChatStyle.bodySize) + #expect(body(view, "hi")?.frame.minX == ChatStyle.bubblePaddingX) } @Test("A bare row has no lift masking path, so nothing casts a bubble-shaped shadow") @@ -381,8 +392,8 @@ struct ChatBubbleViewBareTests { #expect(chrome(view).isDrawingBubble) #expect(chrome(view).backgroundColor != .clear) #expect(view.maskingPath != nil) - #expect(body(view, "hi")?.font.pointSize == 16) - #expect(body(view, "hi")?.frame.minX == 12) + #expect(body(view, "hi")?.font.pointSize == ChatStyle.bodySize) + #expect(body(view, "hi")?.frame.minX == ChatStyle.bubblePaddingX) } @Test("Reconfiguring an ordinary view with a bare message fully reaches the bare state") diff --git a/FlipcashTests/Chat/ChatMediaCellTests.swift b/FlipcashTests/Chat/ChatMediaCellTests.swift index f1ae44356..96ec7f1e7 100644 --- a/FlipcashTests/Chat/ChatMediaCellTests.swift +++ b/FlipcashTests/Chat/ChatMediaCellTests.swift @@ -391,15 +391,24 @@ struct ChatMediaCellTests { @Test("The quote's corners are concentric with the photo's, floored at the grouped radius") func quoteCorners_concentricWithPhoto() { - let rounded = ChatQuotePanelView.photoOverlayRadii(photoTopLeading: BubbleBackgroundView.baseRadius, inset: 4) - #expect(rounded.topLeading == 8) - #expect(rounded.topTrailing == 8) - #expect(rounded.bottomLeading == 8) - #expect(rounded.bottomTrailing == 8) - - let flattened = ChatQuotePanelView.photoOverlayRadii(photoTopLeading: BubbleBackgroundView.groupedRadius, inset: 4) - #expect(flattened.topLeading == BubbleBackgroundView.groupedRadius) - #expect(flattened.topTrailing == 8) + let base = BubbleBackgroundView.baseRadius + let grouped = BubbleBackgroundView.groupedRadius + // One inset inside the photo's round corner, unless that falls under the grouped radius. + let free = max(base - 4, grouped) + + let rounded = ChatQuotePanelView.photoOverlayRadii(photoTopLeading: base, inset: 4) + #expect(rounded.topLeading == free) + #expect(rounded.topTrailing == free) + #expect(rounded.bottomLeading == free) + #expect(rounded.bottomTrailing == free) + + let flattened = ChatQuotePanelView.photoOverlayRadii(photoTopLeading: grouped, inset: 4) + #expect(flattened.topLeading == grouped) + #expect(flattened.topTrailing == free) + + // A photo corner that clears the floor gives a quote corner exactly one inset tighter. + let wide = ChatQuotePanelView.photoOverlayRadii(photoTopLeading: grouped + 10, inset: 4) + #expect(wide.topLeading == grouped + 6) } @Test("The quote reports the row it jumps to") diff --git a/FlipcashTests/Chat/ChatQuoteBubbleTests.swift b/FlipcashTests/Chat/ChatQuoteBubbleTests.swift index 9d3c422aa..a94012672 100644 --- a/FlipcashTests/Chat/ChatQuoteBubbleTests.swift +++ b/FlipcashTests/Chat/ChatQuoteBubbleTests.swift @@ -10,8 +10,8 @@ import UIKit import FlipcashCore @testable import FlipcashUI -/// `ChatBubbleView`'s horizontal padding, 12 on each side of the body. -private let bodyPadding: CGFloat = 24 +/// `ChatBubbleView`'s horizontal padding, the bubble's text inset on each side of the body. +@MainActor private let bodyPadding: CGFloat = ChatStyle.bubblePaddingX * 2 @Suite("Reply bubble quote panel") @MainActor @@ -202,15 +202,15 @@ struct ChatQuoteBubbleGeometryTests { @Test("A quoted reply from me still hugs the trailing edge") func selfReply_hugsTrailing() { let (cell, bubble) = laidOutCell(sender: .me, quote: shortQuote) - #expect(abs(bubble.maxX - (cell.contentView.bounds.width - 12)) < 0.5) - #expect(bubble.minX > 12) + #expect(abs(bubble.maxX - (cell.contentView.bounds.width - ChatColumnCell.rowInset)) < 0.5) + #expect(bubble.minX > ChatColumnCell.rowInset) } @Test("A quoted reply from them still hugs the leading edge") func otherReply_hugsLeading() { let (cell, bubble) = laidOutCell(sender: .other, quote: shortQuote) - #expect(abs(bubble.minX - 12) < 0.5) - #expect(bubble.maxX < cell.contentView.bounds.width - 12) + #expect(abs(bubble.minX - ChatColumnCell.rowInset) < 0.5) + #expect(bubble.maxX < cell.contentView.bounds.width - ChatColumnCell.rowInset) } @Test("A quote wider than the body does not push the bubble past maxWidth") diff --git a/FlipcashTests/Chat/ChatViewControllerTests.swift b/FlipcashTests/Chat/ChatViewControllerTests.swift index a8d599ba5..f102090de 100644 --- a/FlipcashTests/Chat/ChatViewControllerTests.swift +++ b/FlipcashTests/Chat/ChatViewControllerTests.swift @@ -298,7 +298,8 @@ struct ChatRowSpacingTests { @Test("A run from one sender stays tight") func groupedRun_isTight() { let items = [message("1", .me, continuedByNext: true), message("2", .me)] - #expect(gap(after: 0, in: items) == 5) + #expect(gap(after: 0, in: items) == ChatStyle.runGap) + #expect(ChatStyle.runGap < ChatStyle.momentGap) } @Test("Two messages from one sender outside the grouping window take the normal gap") @@ -310,7 +311,8 @@ struct ChatRowSpacingTests { @Test("A change of speaker takes the widest gap") func senderFlip_isWide() { let items = [message("1", .me, continuedByNext: true), message("2", .other)] - #expect(gap(after: 0, in: items) == 15) + #expect(gap(after: 0, in: items) == ChatStyle.speakerGap) + #expect(ChatStyle.speakerGap > ChatStyle.momentGap) } @Test("A date separator takes the normal gap on both sides") diff --git a/FlipcashTests/Chat/ReactionPillStylingTests.swift b/FlipcashTests/Chat/ReactionPillStylingTests.swift index ab306e042..59942005f 100644 --- a/FlipcashTests/Chat/ReactionPillStylingTests.swift +++ b/FlipcashTests/Chat/ReactionPillStylingTests.swift @@ -45,7 +45,7 @@ struct ReactionPillStylingTests { let view = ReactionPillView() view.configure(with: Self.pill(cents: 100, selfReacted: true), canReact: true) #expect(Self.same(view.backgroundColor, Self.gold.withAlphaComponent(0.1))) - #expect(view.layer.borderWidth == 2) + #expect(view.layer.borderWidth == ChatStyle.pillRingWidth) #expect(view.layer.borderColor == Self.gold.cgColor) #expect(Self.same(try #require(Self.amountLabel(in: view)).textColor, Self.gold)) #expect(Self.same(try #require(Self.countLabel(in: view)).textColor, Self.gold.withAlphaComponent(0.5))) @@ -55,19 +55,20 @@ struct ReactionPillStylingTests { func paidByOthers_isNotGold() throws { let view = ReactionPillView() view.configure(with: Self.pill(cents: 100, selfReacted: false), canReact: true) - #expect(Self.same(view.backgroundColor, UIColor.white.withAlphaComponent(0.06))) + #expect(Self.same(view.backgroundColor, ChatStyle.pillFill)) #expect(view.layer.borderWidth == 0) #expect(Self.same(try #require(Self.amountLabel(in: view)).textColor, UIColor.white.withAlphaComponent(0.5))) - #expect(Self.same(try #require(Self.countLabel(in: view)).textColor, UIColor(Color.textMain))) + #expect(Self.same(try #require(Self.countLabel(in: view)).textColor, ChatStyle.secondaryText)) } - @Test("An unpaid pill the viewer reacted to keeps its white outline") - func unpaidSelfReacted_isWhite() { + @Test("An unpaid pill the viewer reacted to takes the accent's tint, ring and count") + func unpaidSelfReacted_isAccent() throws { let view = ReactionPillView() view.configure(with: Self.pill(cents: nil, selfReacted: true), canReact: true) - #expect(Self.same(view.backgroundColor, UIColor.white.withAlphaComponent(0.12))) - #expect(view.layer.borderWidth == 2) - #expect(view.layer.borderColor == UIColor.white.withAlphaComponent(0.24).cgColor) + #expect(Self.same(view.backgroundColor, ChatStyle.accentTint)) + #expect(view.layer.borderWidth == ChatStyle.pillRingWidth) + #expect(view.layer.borderColor == ChatStyle.accentRing.cgColor) + #expect(Self.same(try #require(Self.countLabel(in: view)).textColor, ChatStyle.accentColor)) } @Test("Reacting to someone else's boosted pill turns it gold") @@ -75,7 +76,7 @@ struct ReactionPillStylingTests { let view = ReactionPillView() view.configure(with: Self.pill(cents: 100, selfReacted: false), canReact: true) view.configure(with: Self.pill(cents: 100, selfReacted: true), canReact: true, animated: true) - #expect(view.layer.borderWidth == 2) + #expect(view.layer.borderWidth == ChatStyle.pillRingWidth) #expect(view.layer.borderColor == Self.gold.cgColor) #expect(Self.same(try #require(Self.amountLabel(in: view)).textColor, Self.gold)) } diff --git a/FlipcashTests/Chat/ReactionRowLayoutTests.swift b/FlipcashTests/Chat/ReactionRowLayoutTests.swift index d579833b1..1ecfe54b3 100644 --- a/FlipcashTests/Chat/ReactionRowLayoutTests.swift +++ b/FlipcashTests/Chat/ReactionRowLayoutTests.swift @@ -18,6 +18,9 @@ struct ReactionRowLayoutTests { private static let pill = CGSize(width: 50, height: 28) private static let add = CGSize(width: 28, height: 28) private static let overflow: (Int) -> CGSize = { _ in CGSize(width: 36, height: 28) } + private static let gap = ReactionRowLayout.spacing + /// Two 28pt lines and the gap between them. + private static let twoLines = 28 + gap + 28 private func layout(pills: Int, add: CGSize? = Self.add, width: CGFloat = 200, maxLines: Int? = 2, trailing: Bool = false) -> ReactionRowLayout { ReactionRowLayout.make( @@ -36,7 +39,7 @@ struct ReactionRowLayoutTests { #expect(result.hiddenCount == 0) #expect(result.overflowFrame == nil) #expect(result.pillFrames.count == 2) - #expect(result.addFrame == CGRect(x: 112, y: 0, width: 28, height: 28)) + #expect(result.addFrame == CGRect(x: 2 * (50 + Self.gap), y: 0, width: 28, height: 28)) #expect(result.height == 28) } @@ -45,8 +48,8 @@ struct ReactionRowLayoutTests { // A 200pt line holds three 50pt pills; the fourth starts line two. let result = layout(pills: 4) #expect(result.hiddenCount == 0) - #expect(result.pillFrames[3].minY == 34) - #expect(result.height == 62) + #expect(result.pillFrames[3].minY == 28 + Self.gap) + #expect(result.height == Self.twoLines) } @Test("Past two lines, the tail collapses into an N-more pill ahead of the +") @@ -54,13 +57,13 @@ struct ReactionRowLayoutTests { let result = layout(pills: 10) #expect(result.hiddenCount > 0) #expect(result.pillFrames.count + result.hiddenCount == 10) - #expect(result.height == 62) + #expect(result.height == Self.twoLines) let overflow = try #require(result.overflowFrame) let add = try #require(result.addFrame) - #expect(overflow.minY == 34) - #expect(add.minY == 34) + #expect(overflow.minY == 28 + Self.gap) + #expect(add.minY == 28 + Self.gap) #expect(add.minX > overflow.maxX) - #expect(result.pillFrames.allSatisfy { $0.maxY <= 62 }) + #expect(result.pillFrames.allSatisfy { $0.maxY <= Self.twoLines }) } @Test("Hides only as many pills as the N-more pill needs room for") @@ -76,7 +79,7 @@ struct ReactionRowLayoutTests { let result = layout(pills: 10, maxLines: nil) #expect(result.hiddenCount == 0) #expect(result.pillFrames.count == 10) - #expect(result.height > 62) + #expect(result.height > Self.twoLines) } @Test("A viewer who can't react gets the N-more pill without a +") @@ -89,8 +92,8 @@ struct ReactionRowLayoutTests { @Test("Trailing lines sit against the right edge") func trailingAlignment() { let result = layout(pills: 1, trailing: true) - // The + (28) sits at the edge, and the pill (50) one gap (6) before it. - let pillX: CGFloat = 200 - 28 - 6 - 50 + // The + (28) sits at the edge, and the pill (50) one gap before it. + let pillX: CGFloat = 200 - 28 - Self.gap - 50 #expect(result.addFrame?.maxX == 200) #expect(result.pillFrames.first?.minX == pillX) } diff --git a/FlipcashTests/Navigation/TabBarBadgedIconTests.swift b/FlipcashTests/Navigation/TabBarBadgedIconTests.swift index 7d3cc05c8..f03383702 100644 --- a/FlipcashTests/Navigation/TabBarBadgedIconTests.swift +++ b/FlipcashTests/Navigation/TabBarBadgedIconTests.swift @@ -29,8 +29,8 @@ struct TabBarBadgedIconTests { let image = try #require(TabBarBadgedIcon.itemImages(for: .chat, count: 3)).normal #expect(image.alignmentRectInsets == UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 2)) - #expect(image.size.width - 2 == 32) - #expect(image.size.height - 2 == 32) + #expect(image.size.width - 2 == TabBarItemStyle.glyphSize) + #expect(image.size.height - 2 == TabBarItemStyle.glyphSize) } @Test("a new count renders new images") diff --git a/FlipcashTests/PublicGroupCopyTests.swift b/FlipcashTests/PublicGroupCopyTests.swift new file mode 100644 index 000000000..410bd3fd1 --- /dev/null +++ b/FlipcashTests/PublicGroupCopyTests.swift @@ -0,0 +1,154 @@ +// +// PublicGroupCopyTests.swift +// FlipcashTests +// + +import Foundation +import Testing +@testable import Flipcash +import FlipcashCore +import FlipcashStore + +@MainActor +@Suite("Public group copy") +struct PublicGroupCopyTests { + + private let now = Date(timeIntervalSinceReferenceDate: 800_000_000) + + private func chatter(_ name: String, minutesAgo: Double?, isCreator: Bool = false) -> SampledChatter { + SampledChatter( + userID: UUID(), + profile: Profile(displayName: name, phone: Phone?.none, email: nil), + lastSentAt: minutesAgo.map { now.addingTimeInterval(-$0 * 60) }, + isCreator: isCreator + ) + } + + // MARK: - Lines + + @Test("The summary names the count, singular for one, and drops it while unknown") + func summary() { + #expect(PublicGroupCopy.summary(memberCount: 22) == "Public group · 22 members") + #expect(PublicGroupCopy.summary(memberCount: 1) == "Public group · 1 member") + #expect(PublicGroupCopy.summary(memberCount: 0) == "Public group") + #expect(PublicGroupCopy.summary(memberCount: nil) == "Public group") + } + + @Test("The headline counts the week's chatters, and says quiet for none") + func chatting() { + #expect(PublicGroupCopy.chatting(count: 84) == "84 chatting this week") + #expect(PublicGroupCopy.chatting(count: 1) == "1 chatting this week") + #expect(PublicGroupCopy.chatting(count: 0) == "Quiet this week") + } + + @Test("The last message's age steps through minutes, hours and days") + func lastMessage() { + func age(minutes: Double) -> String { + PublicGroupCopy.lastMessage(at: now.addingTimeInterval(-minutes * 60), now: now) + } + #expect(age(minutes: 0.5) == "just now") + #expect(age(minutes: 2) == "2m ago") + #expect(age(minutes: 3 * 60) == "3h ago") + #expect(age(minutes: 24 * 60) == "1 day ago") + #expect(age(minutes: 12 * 24 * 60) == "12 days ago") + } + + @Test("The meta line leaves out whichever part is unknown") + func pulseMeta() { + let twoMinutesAgo = now.addingTimeInterval(-120) + #expect(PublicGroupCopy.pulseMeta(lastMessageAt: twoMinutesAgo, memberCount: 22, now: now) == "Last message 2m ago · 22 members") + #expect(PublicGroupCopy.pulseMeta(lastMessageAt: nil, memberCount: 22, now: now) == "22 members") + #expect(PublicGroupCopy.pulseMeta(lastMessageAt: twoMinutesAgo, memberCount: nil, now: now) == "Last message 2m ago") + #expect(PublicGroupCopy.pulseMeta(lastMessageAt: nil, memberCount: 0, now: now) == nil) + } + + // MARK: - Pulse + + @Test("Only chatters inside the seven-day window count, newest first") + func pulseWindow() { + let pulse = PublicGroupPulse( + chatters: [ + chatter("Old", minutesAgo: 8 * 24 * 60), + chatter("Quiet", minutesAgo: nil, isCreator: true), + chatter("Recent", minutesAgo: 5), + chatter("Newest", minutesAgo: 1), + ], + now: now + ) + + #expect(pulse.chattingCount == 2) + #expect(pulse.faces.map(\.name) == ["Newest", "Recent"]) + #expect(pulse.overflow == 0) + #expect(pulse.lastMessageAt == now.addingTimeInterval(-60)) + } + + @Test("The facepile caps at five and the rest are the overflow") + func pulseOverflow() { + let chatters = (1...8).map { chatter("Person \($0)", minutesAgo: Double($0)) } + let pulse = PublicGroupPulse(chatters: chatters, now: now) + + #expect(pulse.chattingCount == 8) + #expect(pulse.faces.count == PublicGroupPulse.faceLimit) + #expect(pulse.overflow == 3) + } + + @Test("A sample with nobody inside the window is quiet but still knows the last message") + func pulseQuiet() { + let pulse = PublicGroupPulse(chatters: [chatter("Ted", minutesAgo: 12 * 24 * 60, isCreator: true)], now: now) + + #expect(pulse.chattingCount == 0) + #expect(pulse.faces.isEmpty) + #expect(pulse.lastMessageAt != nil) + } + + // MARK: - Requirement tiles + + private static let usd = DisplayCurrency(currency: .usd, rates: [Rate]()) + + private func requirements(join: Decimal?, chat: Decimal?) -> GroupBalanceRequirements? { + GroupBalanceRequirements(ConversationRules( + listener: join.map { [.minimumBalance(MinimumBalanceRequirement(amount: .usd($0)))] } ?? [], + speaker: chat.map { [.minimumBalance(MinimumBalanceRequirement(amount: .usd($0)))] } ?? [] + )) + } + + @Test("Equal join and chat rules collapse to one tile") + func collapsedTile() throws { + let rules = try #require(requirements(join: 100, chat: 100)) + + let tiles = PublicGroupRequirementTile.tiles(for: rules, metadata: [:], display: Self.usd) + + #expect(tiles.map(\.purpose) == [.joinAndChat]) + #expect(tiles.first?.amount == "$100") + } + + @Test("A join rule with no speaker rule is one tile too") + func joinOnlyTile() throws { + let rules = try #require(requirements(join: 50, chat: nil)) + + #expect(PublicGroupRequirementTile.tiles(for: rules, metadata: [:], display: Self.usd).map(\.purpose) == [.joinAndChat]) + } + + @Test("Different join and chat rules are two tiles") + func splitTiles() throws { + let rules = try #require(requirements(join: 50, chat: 100)) + + let tiles = PublicGroupRequirementTile.tiles(for: rules, metadata: [:], display: Self.usd) + + #expect(tiles.map(\.purpose) == [.join, .chat]) + #expect(tiles.map(\.amount) == ["$50", "$100"]) + } + + @Test("A tile restates its amount in the display currency") + func tileInDisplayCurrency() throws { + let rules = try #require(requirements(join: 50, chat: 100)) + let cad = DisplayCurrency(currency: .cad, rates: [Rate(fx: 2, currency: .cad)]) + + let tiles = PublicGroupRequirementTile.tiles(for: rules, metadata: [:], display: cad) + + #expect(tiles.map(\.amount) == [ + FiatAmount(value: 100, currency: .cad).formattedDroppingZeroFraction(), + FiatAmount(value: 200, currency: .cad).formattedDroppingZeroFraction(), + ]) + } +} diff --git a/FlipcashTests/Swipe/SwipeControlTests.swift b/FlipcashTests/Swipe/SwipeControlTests.swift new file mode 100644 index 000000000..ddbafd7a9 --- /dev/null +++ b/FlipcashTests/Swipe/SwipeControlTests.swift @@ -0,0 +1,38 @@ +// +// SwipeControlTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import CoreGraphics +import Testing +@testable import FlipcashUI + +@Suite("SwipeControl commit threshold") +struct SwipeControlTests { + + @Test("Past 70% of the track commits at any speed") + func commitsPastSeventyPercent() { + #expect(SwipeControl.shouldCommit(percent: 0.71, velocity: 0)) + #expect(SwipeControl.shouldCommit(percent: 1.0, velocity: -500)) + } + + @Test("At or below 70% a slow release snaps back") + func slowReleaseBelowSeventyPercentSnapsBack() { + #expect(!SwipeControl.shouldCommit(percent: 0.69, velocity: 0)) + #expect(!SwipeControl.shouldCommit(percent: 0.7, velocity: 200)) + } + + @Test("Past 50% a fling faster than 200 points commits") + func flingPastHalfCommits() { + #expect(SwipeControl.shouldCommit(percent: 0.51, velocity: 201)) + #expect(!SwipeControl.shouldCommit(percent: 0.51, velocity: 200)) + } + + @Test("At or below 50% even a fast fling snaps back") + func flingBelowHalfSnapsBack() { + #expect(!SwipeControl.shouldCommit(percent: 0.49, velocity: 1000)) + #expect(!SwipeControl.shouldCommit(percent: 0.5, velocity: 1000)) + } +} diff --git a/FlipcashTests/TipCardExportTests.swift b/FlipcashTests/TipCardExportTests.swift index 448a6d735..234671da0 100644 --- a/FlipcashTests/TipCardExportTests.swift +++ b/FlipcashTests/TipCardExportTests.swift @@ -23,8 +23,8 @@ struct TipCardExportTests { let svg = try String(contentsOf: url, encoding: .utf8) #expect(svg.hasPrefix("\n")) } diff --git a/FlipcashTests/Tray/TrayFlowTests.swift b/FlipcashTests/Tray/TrayFlowTests.swift new file mode 100644 index 000000000..e710c8f3d --- /dev/null +++ b/FlipcashTests/Tray/TrayFlowTests.swift @@ -0,0 +1,169 @@ +// +// TrayFlowTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import Testing +@testable import FlipcashUI + +@MainActor +@Suite("TrayFlow") +struct TrayFlowTests { + + @Test("A new flow shows its root with nothing to go back to") + func startsAtRoot() { + let flow = TrayFlow(root: "education") + + #expect(flow.path == ["education"]) + #expect(flow.current == "education") + #expect(!flow.canGoBack) + #expect(flow.direction == .forward) + } + + @Test("Pushing moves forward to the new step") + func pushMovesForward() { + let flow = TrayFlow(root: "education") + + flow.push("address") + + #expect(flow.path == ["education", "address"]) + #expect(flow.current == "address") + #expect(flow.canGoBack) + #expect(flow.direction == .forward) + } + + @Test("Going back returns to the previous step and records the direction") + func backReturns() { + let flow = TrayFlow(root: "education") + flow.push("address") + flow.push("confirm") + + flow.back() + + #expect(flow.current == "address") + #expect(flow.direction == .back) + } + + @Test("The X goes back a step from the second step on") + func closeGoesBack() { + let flow = TrayFlow(root: "education") + flow.push("address") + var dismissed = false + + flow.close { dismissed = true } + + #expect(flow.current == "education") + #expect(flow.direction == .back) + #expect(!dismissed) + } + + @Test("The X dismisses on the root") + func closeDismissesOnRoot() { + let flow = TrayFlow(root: "education") + var dismissed = false + + flow.close { dismissed = true } + + #expect(dismissed) + #expect(flow.path == ["education"]) + } + + @Test("Going back on the root does nothing") + func backOnRootIsIgnored() { + let flow = TrayFlow(root: "education") + + flow.back() + + #expect(flow.path == ["education"]) + #expect(flow.direction == .forward) + } +} + +@MainActor +@Suite("TrayAction") +struct TrayActionTests { + + @Test("The secondary sits left of the primary whatever order they are given in") + func secondaryOrdersFirst() { + let actions = [ + TrayAction("Share") {}, + TrayAction("Don't Share", role: .secondary) {}, + ] + + let ordered = TrayAction.ordered(actions).map(\.title) + + #expect(ordered == ["Don't Share", "Share"]) + } + + @Test("A destructive action takes the primary's place") + func destructiveTakesPrimarySlot() { + let destructive = TrayAction("Log Out", role: .destructive) {} + let primary = TrayAction("OK") {} + let secondary = TrayAction("Cancel", role: .secondary) {} + + #expect(destructive.slot == primary.slot) + #expect(TrayAction.ordered([destructive, secondary]).map(\.title) == ["Cancel", "Log Out"]) + } + + @Test( + "An action takes taps only while enabled and not showing progress", + arguments: [ + (ButtonState.normal, true, true), + (ButtonState.normal, false, false), + (ButtonState.loading, true, false), + (ButtonState.success, true, false), + ] + ) + func interactivity(state: ButtonState, isEnabled: Bool, expected: Bool) { + let action = TrayAction("Save", state: state, isEnabled: isEnabled) {} + + #expect(action.isInteractive == expected) + } +} + +@MainActor +@Suite("TrayMetrics") +struct TrayMetricsTests { + + @Test("Type keeps its tuned size at the default Dynamic Type category") + func scaledKeepsDefault() { + #expect(TrayMetrics.scaled(24, .title2, .large) == 24) + #expect(TrayMetrics.scaled(15, .subheadline, .large) == 15) + } + + @Test("Type grows at larger categories and shrinks at smaller ones") + func scaledFollowsCategory() { + #expect(TrayMetrics.scaled(24, .title2, .accessibility3) > 24) + #expect(TrayMetrics.scaled(24, .title2, .xSmall) < 24) + } + + @Test("Reduce Motion turns the scale press into a dim and leaves the others alone") + func reduceMotionPress() { + #expect(TrayActionPress.scale.resolved(reduceMotion: true) == .dim) + #expect(TrayActionPress.scale.resolved(reduceMotion: false) == .scale) + #expect(TrayActionPress.none.resolved(reduceMotion: true) == .none) + #expect(TrayActionPress.dim.resolved(reduceMotion: true) == .dim) + } + + @Test("Only the error style fades the card red") + func onlyErrorFadesRed() { + #expect(!TrayStyle.standard.fadesRed) + #expect(!TrayStyle.success.fadesRed) + #expect(TrayStyle.error.fadesRed) + } + + @Test("Central icons match by name across steps; images never match") + func iconIdentity() { + #expect(TrayIcon.central(.lock).identity == TrayIcon.central(.lock).identity) + #expect(TrayIcon.central(.lock).identity != TrayIcon.central(.clock).identity) + #expect(TrayIcon.image(Image(systemName: "lock")).identity == nil) + } + + @Test("A destructive tray shows the exclamation circle") + func destructiveIcon() { + #expect(TrayIcon.destructive.identity == TrayIcon.central(.exclamationCircle).identity) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Airplane.imageset/Airplane.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Airplane.imageset/Airplane.svg new file mode 100644 index 000000000..de835e7fd --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Airplane.imageset/Airplane.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Airplane.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Airplane.imageset/Contents.json new file mode 100644 index 000000000..128558d0f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Airplane.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Airplane.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Archive.imageset/Archive.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Archive.imageset/Archive.svg new file mode 100644 index 000000000..69f19e6c2 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Archive.imageset/Archive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Archive.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Archive.imageset/Contents.json new file mode 100644 index 000000000..35dfb9fda --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Archive.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Archive.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowBottomTop.imageset/ArrowBottomTop.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowBottomTop.imageset/ArrowBottomTop.svg new file mode 100644 index 000000000..9d73f18e6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowBottomTop.imageset/ArrowBottomTop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowBottomTop.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowBottomTop.imageset/Contents.json new file mode 100644 index 000000000..857f38ab1 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowBottomTop.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowBottomTop.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowBoxLeft.imageset/ArrowBoxLeft.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowBoxLeft.imageset/ArrowBoxLeft.svg new file mode 100644 index 000000000..ff7d67842 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowBoxLeft.imageset/ArrowBoxLeft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowBoxLeft.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowBoxLeft.imageset/Contents.json new file mode 100644 index 000000000..f9ae797a5 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowBoxLeft.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowBoxLeft.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowCornerLeftDown.imageset/ArrowCornerLeftDown.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowCornerLeftDown.imageset/ArrowCornerLeftDown.svg new file mode 100644 index 000000000..b71919cb1 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowCornerLeftDown.imageset/ArrowCornerLeftDown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowCornerLeftDown.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowCornerLeftDown.imageset/Contents.json new file mode 100644 index 000000000..219374b11 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowCornerLeftDown.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowCornerLeftDown.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowDown.imageset/ArrowDown.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowDown.imageset/ArrowDown.svg new file mode 100644 index 000000000..7221a66e0 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowDown.imageset/ArrowDown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowDown.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowDown.imageset/Contents.json new file mode 100644 index 000000000..2c7306fae --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowDown.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowDown.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowInbox.imageset/ArrowInbox.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowInbox.imageset/ArrowInbox.svg new file mode 100644 index 000000000..adeb94985 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowInbox.imageset/ArrowInbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowInbox.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowInbox.imageset/Contents.json new file mode 100644 index 000000000..e08ef5476 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowInbox.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowInbox.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowLeft.imageset/ArrowLeft.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowLeft.imageset/ArrowLeft.svg new file mode 100644 index 000000000..aea4cf0cd --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowLeft.imageset/ArrowLeft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowLeft.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowLeft.imageset/Contents.json new file mode 100644 index 000000000..3cad02669 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowLeft.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowLeft.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowRight.imageset/ArrowRight.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowRight.imageset/ArrowRight.svg new file mode 100644 index 000000000..7ccc61876 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowRight.imageset/ArrowRight.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowRight.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowRight.imageset/Contents.json new file mode 100644 index 000000000..682090e7e --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowRight.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowRight.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowRotateClockwise.imageset/ArrowRotateClockwise.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowRotateClockwise.imageset/ArrowRotateClockwise.svg new file mode 100644 index 000000000..9637a9da4 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowRotateClockwise.imageset/ArrowRotateClockwise.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowRotateClockwise.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowRotateClockwise.imageset/Contents.json new file mode 100644 index 000000000..1881016d6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowRotateClockwise.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowRotateClockwise.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareLeft.imageset/ArrowShareLeft.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareLeft.imageset/ArrowShareLeft.svg new file mode 100644 index 000000000..e70ef2b66 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareLeft.imageset/ArrowShareLeft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareLeft.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareLeft.imageset/Contents.json new file mode 100644 index 000000000..f58c864e5 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareLeft.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowShareLeft.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareLeftFilled.imageset/ArrowShareLeftFilled.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareLeftFilled.imageset/ArrowShareLeftFilled.svg new file mode 100644 index 000000000..5e47db0b6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareLeftFilled.imageset/ArrowShareLeftFilled.svg @@ -0,0 +1 @@ + diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareLeftFilled.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareLeftFilled.imageset/Contents.json new file mode 100644 index 000000000..cac22ece9 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareLeftFilled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "ArrowShareLeftFilled.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareRight.imageset/ArrowShareRight.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareRight.imageset/ArrowShareRight.svg new file mode 100644 index 000000000..2b2c3c182 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareRight.imageset/ArrowShareRight.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareRight.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareRight.imageset/Contents.json new file mode 100644 index 000000000..de4aa2a24 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareRight.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowShareRight.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowUp.imageset/ArrowUp.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowUp.imageset/ArrowUp.svg new file mode 100644 index 000000000..201a21560 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowUp.imageset/ArrowUp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowUp.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowUp.imageset/Contents.json new file mode 100644 index 000000000..b9dd3e332 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowUp.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowUp.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowUpWall.imageset/ArrowUpWall.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowUpWall.imageset/ArrowUpWall.svg new file mode 100644 index 000000000..744ce744d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowUpWall.imageset/ArrowUpWall.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowUpWall.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowUpWall.imageset/Contents.json new file mode 100644 index 000000000..484697e3f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowUpWall.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowUpWall.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowWallDown.imageset/ArrowWallDown.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowWallDown.imageset/ArrowWallDown.svg new file mode 100644 index 000000000..c574c7959 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowWallDown.imageset/ArrowWallDown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowWallDown.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowWallDown.imageset/Contents.json new file mode 100644 index 000000000..05006297d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowWallDown.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowWallDown.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowsRepeat.imageset/ArrowsRepeat.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowsRepeat.imageset/ArrowsRepeat.svg new file mode 100644 index 000000000..ee1c46d1e --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowsRepeat.imageset/ArrowsRepeat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowsRepeat.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowsRepeat.imageset/Contents.json new file mode 100644 index 000000000..e56c3214a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowsRepeat.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowsRepeat.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/At.imageset/At.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/At.imageset/At.svg new file mode 100644 index 000000000..a32f943b4 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/At.imageset/At.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/At.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/At.imageset/Contents.json new file mode 100644 index 000000000..8ef9c2ec3 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/At.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "At.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Banknote1.imageset/Banknote1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Banknote1.imageset/Banknote1.svg new file mode 100644 index 000000000..4ce5cd567 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Banknote1.imageset/Banknote1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Banknote1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Banknote1.imageset/Contents.json new file mode 100644 index 000000000..72c800346 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Banknote1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Banknote1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bell2.imageset/Bell2.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bell2.imageset/Bell2.svg new file mode 100644 index 000000000..9a59846c9 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bell2.imageset/Bell2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bell2.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bell2.imageset/Contents.json new file mode 100644 index 000000000..b9b1e25bf --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bell2.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Bell2.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bell2Off.imageset/Bell2Off.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bell2Off.imageset/Bell2Off.svg new file mode 100644 index 000000000..72d184652 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bell2Off.imageset/Bell2Off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bell2Off.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bell2Off.imageset/Contents.json new file mode 100644 index 000000000..2f73be60c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bell2Off.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Bell2Off.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BellOff.imageset/BellOff.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BellOff.imageset/BellOff.svg new file mode 100644 index 000000000..657fcc964 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BellOff.imageset/BellOff.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BellOff.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BellOff.imageset/Contents.json new file mode 100644 index 000000000..991906482 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BellOff.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "BellOff.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BezierCurve.imageset/BezierCurve.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BezierCurve.imageset/BezierCurve.svg new file mode 100644 index 000000000..13714775a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BezierCurve.imageset/BezierCurve.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BezierCurve.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BezierCurve.imageset/Contents.json new file mode 100644 index 000000000..b7b3cebad --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BezierCurve.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "BezierCurve.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bubble2.imageset/Bubble2.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bubble2.imageset/Bubble2.svg new file mode 100644 index 000000000..5c037a5f1 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bubble2.imageset/Bubble2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bubble2.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bubble2.imageset/Contents.json new file mode 100644 index 000000000..e0c92800a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bubble2.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Bubble2.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BubbleAlert.imageset/BubbleAlert.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BubbleAlert.imageset/BubbleAlert.svg new file mode 100644 index 000000000..0e6f52349 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BubbleAlert.imageset/BubbleAlert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BubbleAlert.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BubbleAlert.imageset/Contents.json new file mode 100644 index 000000000..4602cd66b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BubbleAlert.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "BubbleAlert.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BubbleCrossed.imageset/BubbleCrossed.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BubbleCrossed.imageset/BubbleCrossed.svg new file mode 100644 index 000000000..80ca7ed92 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BubbleCrossed.imageset/BubbleCrossed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BubbleCrossed.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BubbleCrossed.imageset/Contents.json new file mode 100644 index 000000000..db874b1c2 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BubbleCrossed.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "BubbleCrossed.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bug.imageset/Bug.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bug.imageset/Bug.svg new file mode 100644 index 000000000..541bdeaa9 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bug.imageset/Bug.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bug.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bug.imageset/Contents.json new file mode 100644 index 000000000..f34e5f815 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bug.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Bug.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Camera1.imageset/Camera1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Camera1.imageset/Camera1.svg new file mode 100644 index 000000000..b62a2bd38 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Camera1.imageset/Camera1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Camera1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Camera1.imageset/Contents.json new file mode 100644 index 000000000..13533367a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Camera1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Camera1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChainLink4.imageset/ChainLink4.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChainLink4.imageset/ChainLink4.svg new file mode 100644 index 000000000..7302845ef --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChainLink4.imageset/ChainLink4.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChainLink4.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChainLink4.imageset/Contents.json new file mode 100644 index 000000000..2fe323e8c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChainLink4.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ChainLink4.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChatBubbles.imageset/ChatBubbles.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChatBubbles.imageset/ChatBubbles.svg new file mode 100644 index 000000000..3a20388f2 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChatBubbles.imageset/ChatBubbles.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChatBubbles.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChatBubbles.imageset/Contents.json new file mode 100644 index 000000000..0ab5226d6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChatBubbles.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ChatBubbles.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChatBubblesFilled.imageset/ChatBubblesFilled.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChatBubblesFilled.imageset/ChatBubblesFilled.svg new file mode 100644 index 000000000..9a3817cd7 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChatBubblesFilled.imageset/ChatBubblesFilled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChatBubblesFilled.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChatBubblesFilled.imageset/Contents.json new file mode 100644 index 000000000..b66467f8d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChatBubblesFilled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ChatBubblesFilled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Checklist.imageset/Checklist.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Checklist.imageset/Checklist.svg new file mode 100644 index 000000000..0ca438ba9 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Checklist.imageset/Checklist.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Checklist.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Checklist.imageset/Contents.json new file mode 100644 index 000000000..61bf416b7 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Checklist.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Checklist.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Checkmark1.imageset/Checkmark1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Checkmark1.imageset/Checkmark1.svg new file mode 100644 index 000000000..d91d1c531 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Checkmark1.imageset/Checkmark1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Checkmark1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Checkmark1.imageset/Contents.json new file mode 100644 index 000000000..f5d617cb2 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Checkmark1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Checkmark1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronDownMedium.imageset/ChevronDownMedium.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronDownMedium.imageset/ChevronDownMedium.svg new file mode 100644 index 000000000..edc0f2119 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronDownMedium.imageset/ChevronDownMedium.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronDownMedium.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronDownMedium.imageset/Contents.json new file mode 100644 index 000000000..8cb96f20b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronDownMedium.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ChevronDownMedium.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronLeftMedium.imageset/ChevronLeftMedium.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronLeftMedium.imageset/ChevronLeftMedium.svg new file mode 100644 index 000000000..59a849f01 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronLeftMedium.imageset/ChevronLeftMedium.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronLeftMedium.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronLeftMedium.imageset/Contents.json new file mode 100644 index 000000000..3dbdf1b9e --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronLeftMedium.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ChevronLeftMedium.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronRightMedium.imageset/ChevronRightMedium.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronRightMedium.imageset/ChevronRightMedium.svg new file mode 100644 index 000000000..83fd5e092 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronRightMedium.imageset/ChevronRightMedium.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronRightMedium.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronRightMedium.imageset/Contents.json new file mode 100644 index 000000000..303b618c9 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronRightMedium.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ChevronRightMedium.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleArrowDown.imageset/CircleArrowDown.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleArrowDown.imageset/CircleArrowDown.svg new file mode 100644 index 000000000..3e266fd63 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleArrowDown.imageset/CircleArrowDown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleArrowDown.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleArrowDown.imageset/Contents.json new file mode 100644 index 000000000..336a7fd15 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleArrowDown.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CircleArrowDown.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleBanSign.imageset/CircleBanSign.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleBanSign.imageset/CircleBanSign.svg new file mode 100644 index 000000000..8b4429262 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleBanSign.imageset/CircleBanSign.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleBanSign.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleBanSign.imageset/Contents.json new file mode 100644 index 000000000..383bf6378 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleBanSign.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CircleBanSign.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleCheck.imageset/CircleCheck.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleCheck.imageset/CircleCheck.svg new file mode 100644 index 000000000..5a0203f52 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleCheck.imageset/CircleCheck.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleCheck.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleCheck.imageset/Contents.json new file mode 100644 index 000000000..f8942d3c6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleCheck.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CircleCheck.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleDotted.imageset/CircleDotted.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleDotted.imageset/CircleDotted.svg new file mode 100644 index 000000000..05246283e --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleDotted.imageset/CircleDotted.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleDotted.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleDotted.imageset/Contents.json new file mode 100644 index 000000000..13a41e351 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleDotted.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CircleDotted.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleInfo.imageset/CircleInfo.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleInfo.imageset/CircleInfo.svg new file mode 100644 index 000000000..241d6d038 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleInfo.imageset/CircleInfo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleInfo.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleInfo.imageset/Contents.json new file mode 100644 index 000000000..9c1bdf770 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleInfo.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CircleInfo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleMinus.imageset/CircleMinus.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleMinus.imageset/CircleMinus.svg new file mode 100644 index 000000000..d0b757301 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleMinus.imageset/CircleMinus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleMinus.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleMinus.imageset/Contents.json new file mode 100644 index 000000000..7d8fc6a53 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleMinus.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CircleMinus.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CirclePerson.imageset/CirclePerson.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CirclePerson.imageset/CirclePerson.svg new file mode 100644 index 000000000..931ab5965 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CirclePerson.imageset/CirclePerson.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CirclePerson.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CirclePerson.imageset/Contents.json new file mode 100644 index 000000000..f5b56aaa2 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CirclePerson.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CirclePerson.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CirclePlus.imageset/CirclePlus.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CirclePlus.imageset/CirclePlus.svg new file mode 100644 index 000000000..c9c817c16 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CirclePlus.imageset/CirclePlus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CirclePlus.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CirclePlus.imageset/Contents.json new file mode 100644 index 000000000..f0d71e5c6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CirclePlus.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CirclePlus.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleX.imageset/CircleX.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleX.imageset/CircleX.svg new file mode 100644 index 000000000..7b0dbdaa2 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleX.imageset/CircleX.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleX.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleX.imageset/Contents.json new file mode 100644 index 000000000..326905b75 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleX.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CircleX.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleXFilled.imageset/CircleXFilled.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleXFilled.imageset/CircleXFilled.svg new file mode 100644 index 000000000..9a1d1f4ba --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleXFilled.imageset/CircleXFilled.svg @@ -0,0 +1 @@ + diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleXFilled.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleXFilled.imageset/Contents.json new file mode 100644 index 000000000..f5ea3073b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleXFilled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "CircleXFilled.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Clipboard.imageset/Clipboard.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Clipboard.imageset/Clipboard.svg new file mode 100644 index 000000000..ec4166e85 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Clipboard.imageset/Clipboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Clipboard.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Clipboard.imageset/Contents.json new file mode 100644 index 000000000..db497ee42 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Clipboard.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Clipboard.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Clock.imageset/Clock.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Clock.imageset/Clock.svg new file mode 100644 index 000000000..8c584cda9 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Clock.imageset/Clock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Clock.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Clock.imageset/Contents.json new file mode 100644 index 000000000..7ea624f54 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Clock.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Clock.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Coins.imageset/Coins.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Coins.imageset/Coins.svg new file mode 100644 index 000000000..67f91b587 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Coins.imageset/Coins.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Coins.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Coins.imageset/Contents.json new file mode 100644 index 000000000..e7c9ea255 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Coins.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Coins.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CoinsAdd.imageset/CoinsAdd.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CoinsAdd.imageset/CoinsAdd.svg new file mode 100644 index 000000000..cf1da6d7c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CoinsAdd.imageset/CoinsAdd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CoinsAdd.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CoinsAdd.imageset/Contents.json new file mode 100644 index 000000000..b5885551f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CoinsAdd.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CoinsAdd.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CreditCard1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CreditCard1.imageset/Contents.json new file mode 100644 index 000000000..a1b081edd --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CreditCard1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CreditCard1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CreditCard1.imageset/CreditCard1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CreditCard1.imageset/CreditCard1.svg new file mode 100644 index 000000000..aa13a75e3 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CreditCard1.imageset/CreditCard1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CrossMedium.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CrossMedium.imageset/Contents.json new file mode 100644 index 000000000..16714a880 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CrossMedium.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CrossMedium.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CrossMedium.imageset/CrossMedium.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CrossMedium.imageset/CrossMedium.svg new file mode 100644 index 000000000..9acca93ec --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CrossMedium.imageset/CrossMedium.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Crown.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Crown.imageset/Contents.json new file mode 100644 index 000000000..33ab9ac69 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Crown.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Crown.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Crown.imageset/Crown.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Crown.imageset/Crown.svg new file mode 100644 index 000000000..6af17eb83 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Crown.imageset/Crown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/DotGrid1x3Horizontal.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/DotGrid1x3Horizontal.imageset/Contents.json new file mode 100644 index 000000000..700751126 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/DotGrid1x3Horizontal.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "DotGrid1x3Horizontal.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/DotGrid1x3Horizontal.imageset/DotGrid1x3Horizontal.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/DotGrid1x3Horizontal.imageset/DotGrid1x3Horizontal.svg new file mode 100644 index 000000000..9a9b0fe3a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/DotGrid1x3Horizontal.imageset/DotGrid1x3Horizontal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/DotGrid1x3Vertical.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/DotGrid1x3Vertical.imageset/Contents.json new file mode 100644 index 000000000..2b066bcdf --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/DotGrid1x3Vertical.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "DotGrid1x3Vertical.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/DotGrid1x3Vertical.imageset/DotGrid1x3Vertical.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/DotGrid1x3Vertical.imageset/DotGrid1x3Vertical.svg new file mode 100644 index 000000000..21a6443fc --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/DotGrid1x3Vertical.imageset/DotGrid1x3Vertical.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Email1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Email1.imageset/Contents.json new file mode 100644 index 000000000..3b0309757 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Email1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Email1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Email1.imageset/Email1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Email1.imageset/Email1.svg new file mode 100644 index 000000000..63a3c178d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Email1.imageset/Email1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/EmojiAddReaction.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/EmojiAddReaction.imageset/Contents.json new file mode 100644 index 000000000..058cd50b1 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/EmojiAddReaction.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "EmojiAddReaction.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/EmojiAddReaction.imageset/EmojiAddReaction.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/EmojiAddReaction.imageset/EmojiAddReaction.svg new file mode 100644 index 000000000..27e4fb967 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/EmojiAddReaction.imageset/EmojiAddReaction.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ExclamationCircle.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ExclamationCircle.imageset/Contents.json new file mode 100644 index 000000000..2da3d3b33 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ExclamationCircle.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ExclamationCircle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ExclamationCircle.imageset/ExclamationCircle.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ExclamationCircle.imageset/ExclamationCircle.svg new file mode 100644 index 000000000..ec035b933 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ExclamationCircle.imageset/ExclamationCircle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ExclamationTriangle.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ExclamationTriangle.imageset/Contents.json new file mode 100644 index 000000000..4439746eb --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ExclamationTriangle.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ExclamationTriangle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ExclamationTriangle.imageset/ExclamationTriangle.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ExclamationTriangle.imageset/ExclamationTriangle.svg new file mode 100644 index 000000000..e6f049d90 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ExclamationTriangle.imageset/ExclamationTriangle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FaceId.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FaceId.imageset/Contents.json new file mode 100644 index 000000000..614fc3cb9 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FaceId.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "FaceId.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FaceId.imageset/FaceId.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FaceId.imageset/FaceId.svg new file mode 100644 index 000000000..fcafef1b6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FaceId.imageset/FaceId.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileBend.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileBend.imageset/Contents.json new file mode 100644 index 000000000..4c81e6867 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileBend.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "FileBend.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileBend.imageset/FileBend.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileBend.imageset/FileBend.svg new file mode 100644 index 000000000..ad70b70a1 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileBend.imageset/FileBend.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileDownload.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileDownload.imageset/Contents.json new file mode 100644 index 000000000..d90d5ca75 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileDownload.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "FileDownload.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileDownload.imageset/FileDownload.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileDownload.imageset/FileDownload.svg new file mode 100644 index 000000000..df1b584a0 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileDownload.imageset/FileDownload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileText.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileText.imageset/Contents.json new file mode 100644 index 000000000..d192ec4b5 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileText.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "FileText.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileText.imageset/FileText.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileText.imageset/FileText.svg new file mode 100644 index 000000000..794d7b2fd --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileText.imageset/FileText.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FingerPrint1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FingerPrint1.imageset/Contents.json new file mode 100644 index 000000000..0531d0053 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FingerPrint1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "FingerPrint1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FingerPrint1.imageset/FingerPrint1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FingerPrint1.imageset/FingerPrint1.svg new file mode 100644 index 000000000..7c702662f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FingerPrint1.imageset/FingerPrint1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Flag1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Flag1.imageset/Contents.json new file mode 100644 index 000000000..a3c5c7f5b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Flag1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Flag1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Flag1.imageset/Flag1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Flag1.imageset/Flag1.svg new file mode 100644 index 000000000..39d8be325 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Flag1.imageset/Flag1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Folder1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Folder1.imageset/Contents.json new file mode 100644 index 000000000..7ec563250 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Folder1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Folder1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Folder1.imageset/Folder1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Folder1.imageset/Folder1.svg new file mode 100644 index 000000000..8a040096b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Folder1.imageset/Folder1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Gift1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Gift1.imageset/Contents.json new file mode 100644 index 000000000..b192b0d2e --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Gift1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Gift1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Gift1.imageset/Gift1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Gift1.imageset/Gift1.svg new file mode 100644 index 000000000..3e9136f9f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Gift1.imageset/Gift1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Globe.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Globe.imageset/Contents.json new file mode 100644 index 000000000..0a8ba3c96 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Globe.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Globe.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Globe.imageset/Globe.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Globe.imageset/Globe.svg new file mode 100644 index 000000000..d808f72fa --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Globe.imageset/Globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Grid.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Grid.imageset/Contents.json new file mode 100644 index 000000000..e941936fe --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Grid.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Grid.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Grid.imageset/Grid.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Grid.imageset/Grid.svg new file mode 100644 index 000000000..09aa9845d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Grid.imageset/Grid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Group1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Group1.imageset/Contents.json new file mode 100644 index 000000000..c0b7c2b07 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Group1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Group1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Group1.imageset/Group1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Group1.imageset/Group1.svg new file mode 100644 index 000000000..d38bff926 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Group1.imageset/Group1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Group3.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Group3.imageset/Contents.json new file mode 100644 index 000000000..d8f84079f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Group3.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Group3.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Group3.imageset/Group3.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Group3.imageset/Group3.svg new file mode 100644 index 000000000..c474b3fc6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Group3.imageset/Group3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Hand5Finger.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Hand5Finger.imageset/Contents.json new file mode 100644 index 000000000..57febe68d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Hand5Finger.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Hand5Finger.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Hand5Finger.imageset/Hand5Finger.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Hand5Finger.imageset/Hand5Finger.svg new file mode 100644 index 000000000..d961a9e88 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Hand5Finger.imageset/Hand5Finger.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/History.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/History.imageset/Contents.json new file mode 100644 index 000000000..56b7404fd --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/History.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "History.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/History.imageset/History.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/History.imageset/History.svg new file mode 100644 index 000000000..a8f2ada9a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/History.imageset/History.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Images1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Images1.imageset/Contents.json new file mode 100644 index 000000000..6ba2822f4 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Images1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Images1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Images1.imageset/Images1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Images1.imageset/Images1.svg new file mode 100644 index 000000000..3cc47e894 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Images1.imageset/Images1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Images3.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Images3.imageset/Contents.json new file mode 100644 index 000000000..b5e5c8c8a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Images3.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Images3.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Images3.imageset/Images3.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Images3.imageset/Images3.svg new file mode 100644 index 000000000..35564c64c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Images3.imageset/Images3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Invite.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Invite.imageset/Contents.json new file mode 100644 index 000000000..43eca109a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Invite.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Invite.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Invite.imageset/Invite.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Invite.imageset/Invite.svg new file mode 100644 index 000000000..7e58b3dcf --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Invite.imageset/Invite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Key1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Key1.imageset/Contents.json new file mode 100644 index 000000000..95409e372 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Key1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Key1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Key1.imageset/Key1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Key1.imageset/Key1.svg new file mode 100644 index 000000000..e437f2007 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Key1.imageset/Key1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/KikCode.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/KikCode.imageset/Contents.json new file mode 100644 index 000000000..b229774ad --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/KikCode.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "KikCode.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/KikCode.imageset/KikCode.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/KikCode.imageset/KikCode.svg new file mode 100644 index 000000000..abffef84a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/KikCode.imageset/KikCode.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/KikCodeFilled.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/KikCodeFilled.imageset/Contents.json new file mode 100644 index 000000000..bdbe8aefb --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/KikCodeFilled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "KikCodeFilled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/KikCodeFilled.imageset/KikCodeFilled.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/KikCodeFilled.imageset/KikCodeFilled.svg new file mode 100644 index 000000000..abffef84a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/KikCodeFilled.imageset/KikCodeFilled.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/LightningBolt.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/LightningBolt.imageset/Contents.json new file mode 100644 index 000000000..4e70ef75e --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/LightningBolt.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "LightningBolt.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/LightningBolt.imageset/LightningBolt.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/LightningBolt.imageset/LightningBolt.svg new file mode 100644 index 000000000..e32693b79 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/LightningBolt.imageset/LightningBolt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/LightningBoltOff.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/LightningBoltOff.imageset/Contents.json new file mode 100644 index 000000000..912df81cc --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/LightningBoltOff.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "LightningBoltOff.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/LightningBoltOff.imageset/LightningBoltOff.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/LightningBoltOff.imageset/LightningBoltOff.svg new file mode 100644 index 000000000..5a8a56c16 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/LightningBoltOff.imageset/LightningBoltOff.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Lock.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Lock.imageset/Contents.json new file mode 100644 index 000000000..2a8b29c95 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Lock.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Lock.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Lock.imageset/Lock.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Lock.imageset/Lock.svg new file mode 100644 index 000000000..8ad5cfd6c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Lock.imageset/Lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/MagnifyingGlass.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/MagnifyingGlass.imageset/Contents.json new file mode 100644 index 000000000..e54f05332 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/MagnifyingGlass.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "MagnifyingGlass.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/MagnifyingGlass.imageset/MagnifyingGlass.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/MagnifyingGlass.imageset/MagnifyingGlass.svg new file mode 100644 index 000000000..829ed860c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/MagnifyingGlass.imageset/MagnifyingGlass.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Maintenance.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Maintenance.imageset/Contents.json new file mode 100644 index 000000000..98dbf2857 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Maintenance.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Maintenance.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Maintenance.imageset/Maintenance.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Maintenance.imageset/Maintenance.svg new file mode 100644 index 000000000..6a69d3085 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Maintenance.imageset/Maintenance.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PaperPlaneTopRight.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PaperPlaneTopRight.imageset/Contents.json new file mode 100644 index 000000000..fa298b679 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PaperPlaneTopRight.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "PaperPlaneTopRight.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PaperPlaneTopRight.imageset/PaperPlaneTopRight.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PaperPlaneTopRight.imageset/PaperPlaneTopRight.svg new file mode 100644 index 000000000..c02d5032a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PaperPlaneTopRight.imageset/PaperPlaneTopRight.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Pencil2.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Pencil2.imageset/Contents.json new file mode 100644 index 000000000..8cd12b9c3 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Pencil2.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Pencil2.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Pencil2.imageset/Pencil2.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Pencil2.imageset/Pencil2.svg new file mode 100644 index 000000000..ba80fafad --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Pencil2.imageset/Pencil2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/People.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/People.imageset/Contents.json new file mode 100644 index 000000000..cc3b33102 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/People.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "People.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/People.imageset/People.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/People.imageset/People.svg new file mode 100644 index 000000000..c194787e7 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/People.imageset/People.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleCircle.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleCircle.imageset/Contents.json new file mode 100644 index 000000000..06ce42047 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleCircle.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "PeopleCircle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleCircle.imageset/PeopleCircle.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleCircle.imageset/PeopleCircle.svg new file mode 100644 index 000000000..7b0a70fe0 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleCircle.imageset/PeopleCircle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleCircleFilled.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleCircleFilled.imageset/Contents.json new file mode 100644 index 000000000..bf46153b9 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleCircleFilled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "PeopleCircleFilled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleCircleFilled.imageset/PeopleCircleFilled.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleCircleFilled.imageset/PeopleCircleFilled.svg new file mode 100644 index 000000000..ef3ac822c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleCircleFilled.imageset/PeopleCircleFilled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleGear.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleGear.imageset/Contents.json new file mode 100644 index 000000000..22c640ee5 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleGear.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "PeopleGear.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleGear.imageset/PeopleGear.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleGear.imageset/PeopleGear.svg new file mode 100644 index 000000000..f8097878f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleGear.imageset/PeopleGear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleIdCard.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleIdCard.imageset/Contents.json new file mode 100644 index 000000000..9e012e4c6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleIdCard.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "PeopleIdCard.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleIdCard.imageset/PeopleIdCard.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleIdCard.imageset/PeopleIdCard.svg new file mode 100644 index 000000000..b9b303753 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleIdCard.imageset/PeopleIdCard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Phone.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Phone.imageset/Contents.json new file mode 100644 index 000000000..0630d4dff --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Phone.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Phone.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Phone.imageset/Phone.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Phone.imageset/Phone.svg new file mode 100644 index 000000000..b016ff620 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Phone.imageset/Phone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PlusLarge.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PlusLarge.imageset/Contents.json new file mode 100644 index 000000000..167280cb3 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PlusLarge.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "PlusLarge.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PlusLarge.imageset/PlusLarge.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PlusLarge.imageset/PlusLarge.svg new file mode 100644 index 000000000..5f982d4d7 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PlusLarge.imageset/PlusLarge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/QrCode.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/QrCode.imageset/Contents.json new file mode 100644 index 000000000..e99d58405 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/QrCode.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "QrCode.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/QrCode.imageset/QrCode.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/QrCode.imageset/QrCode.svg new file mode 100644 index 000000000..9aea3112f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/QrCode.imageset/QrCode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/QuestionmarkCircle.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/QuestionmarkCircle.imageset/Contents.json new file mode 100644 index 000000000..2e9d65f35 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/QuestionmarkCircle.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "QuestionmarkCircle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/QuestionmarkCircle.imageset/QuestionmarkCircle.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/QuestionmarkCircle.imageset/QuestionmarkCircle.svg new file mode 100644 index 000000000..9846ff7ab --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/QuestionmarkCircle.imageset/QuestionmarkCircle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Send.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Send.imageset/Contents.json new file mode 100644 index 000000000..ce8a894d2 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Send.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Send.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Send.imageset/Send.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Send.imageset/Send.svg new file mode 100644 index 000000000..b6b82308b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Send.imageset/Send.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SettingsGear2.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SettingsGear2.imageset/Contents.json new file mode 100644 index 000000000..b5a056200 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SettingsGear2.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "SettingsGear2.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SettingsGear2.imageset/SettingsGear2.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SettingsGear2.imageset/SettingsGear2.svg new file mode 100644 index 000000000..44a16f368 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SettingsGear2.imageset/SettingsGear2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SettingsSliderThree.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SettingsSliderThree.imageset/Contents.json new file mode 100644 index 000000000..797ec2213 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SettingsSliderThree.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "SettingsSliderThree.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SettingsSliderThree.imageset/SettingsSliderThree.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SettingsSliderThree.imageset/SettingsSliderThree.svg new file mode 100644 index 000000000..f2bf8121d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SettingsSliderThree.imageset/SettingsSliderThree.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ShareOs.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ShareOs.imageset/Contents.json new file mode 100644 index 000000000..311e9364c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ShareOs.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ShareOs.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ShareOs.imageset/ShareOs.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ShareOs.imageset/ShareOs.svg new file mode 100644 index 000000000..063e0ffa1 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ShareOs.imageset/ShareOs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SquareBehindSquare1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SquareBehindSquare1.imageset/Contents.json new file mode 100644 index 000000000..964cf5216 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SquareBehindSquare1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "SquareBehindSquare1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SquareBehindSquare1.imageset/SquareBehindSquare1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SquareBehindSquare1.imageset/SquareBehindSquare1.svg new file mode 100644 index 000000000..1d69d7a07 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SquareBehindSquare1.imageset/SquareBehindSquare1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SquareX.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SquareX.imageset/Contents.json new file mode 100644 index 000000000..854e59dac --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SquareX.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "SquareX.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SquareX.imageset/SquareX.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SquareX.imageset/SquareX.svg new file mode 100644 index 000000000..9dc8db5e9 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SquareX.imageset/SquareX.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/TextSize.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/TextSize.imageset/Contents.json new file mode 100644 index 000000000..578874f73 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/TextSize.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "TextSize.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/TextSize.imageset/TextSize.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/TextSize.imageset/TextSize.svg new file mode 100644 index 000000000..b62c4f9ba --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/TextSize.imageset/TextSize.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/TrashCan.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/TrashCan.imageset/Contents.json new file mode 100644 index 000000000..7130060d2 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/TrashCan.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "TrashCan.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/TrashCan.imageset/TrashCan.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/TrashCan.imageset/TrashCan.svg new file mode 100644 index 000000000..92f048882 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/TrashCan.imageset/TrashCan.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Trophy.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Trophy.imageset/Contents.json new file mode 100644 index 000000000..f98884329 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Trophy.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Trophy.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Trophy.imageset/Trophy.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Trophy.imageset/Trophy.svg new file mode 100644 index 000000000..150638d21 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Trophy.imageset/Trophy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/User.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/User.imageset/Contents.json new file mode 100644 index 000000000..5b0c4b95d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/User.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "User.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/User.imageset/User.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/User.imageset/User.svg new file mode 100644 index 000000000..8d388bf0f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/User.imageset/User.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/UserAdd.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/UserAdd.imageset/Contents.json new file mode 100644 index 000000000..0e991b686 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/UserAdd.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "UserAdd.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/UserAdd.imageset/UserAdd.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/UserAdd.imageset/UserAdd.svg new file mode 100644 index 000000000..f74c7be47 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/UserAdd.imageset/UserAdd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/VolumeOff.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/VolumeOff.imageset/Contents.json new file mode 100644 index 000000000..e2ff371d3 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/VolumeOff.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "VolumeOff.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/VolumeOff.imageset/VolumeOff.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/VolumeOff.imageset/VolumeOff.svg new file mode 100644 index 000000000..cf4c9496f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/VolumeOff.imageset/VolumeOff.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet1.imageset/Contents.json new file mode 100644 index 000000000..33031b43c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Wallet1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet1.imageset/Wallet1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet1.imageset/Wallet1.svg new file mode 100644 index 000000000..069050093 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet1.imageset/Wallet1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet5.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet5.imageset/Contents.json new file mode 100644 index 000000000..d756366ef --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet5.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Wallet5.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet5.imageset/Wallet5.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet5.imageset/Wallet5.svg new file mode 100644 index 000000000..c40eacd65 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet5.imageset/Wallet5.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet5Filled.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet5Filled.imageset/Contents.json new file mode 100644 index 000000000..b21a85821 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet5Filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Wallet5Filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet5Filled.imageset/Wallet5Filled.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet5Filled.imageset/Wallet5Filled.svg new file mode 100644 index 000000000..cb581897a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet5Filled.imageset/Wallet5Filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Airplane.imageset/Airplane.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Airplane.imageset/Airplane.svg new file mode 100644 index 000000000..0c4e1939c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Airplane.imageset/Airplane.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Airplane.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Airplane.imageset/Contents.json new file mode 100644 index 000000000..128558d0f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Airplane.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Airplane.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Archive.imageset/Archive.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Archive.imageset/Archive.svg new file mode 100644 index 000000000..76c97ac64 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Archive.imageset/Archive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Archive.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Archive.imageset/Contents.json new file mode 100644 index 000000000..35dfb9fda --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Archive.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Archive.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowBottomTop.imageset/ArrowBottomTop.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowBottomTop.imageset/ArrowBottomTop.svg new file mode 100644 index 000000000..416886ecd --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowBottomTop.imageset/ArrowBottomTop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowBottomTop.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowBottomTop.imageset/Contents.json new file mode 100644 index 000000000..857f38ab1 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowBottomTop.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowBottomTop.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowBoxLeft.imageset/ArrowBoxLeft.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowBoxLeft.imageset/ArrowBoxLeft.svg new file mode 100644 index 000000000..6f336f752 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowBoxLeft.imageset/ArrowBoxLeft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowBoxLeft.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowBoxLeft.imageset/Contents.json new file mode 100644 index 000000000..f9ae797a5 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowBoxLeft.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowBoxLeft.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowCornerLeftDown.imageset/ArrowCornerLeftDown.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowCornerLeftDown.imageset/ArrowCornerLeftDown.svg new file mode 100644 index 000000000..9f476f9e4 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowCornerLeftDown.imageset/ArrowCornerLeftDown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowCornerLeftDown.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowCornerLeftDown.imageset/Contents.json new file mode 100644 index 000000000..219374b11 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowCornerLeftDown.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowCornerLeftDown.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowDown.imageset/ArrowDown.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowDown.imageset/ArrowDown.svg new file mode 100644 index 000000000..d7bf988f7 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowDown.imageset/ArrowDown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowDown.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowDown.imageset/Contents.json new file mode 100644 index 000000000..2c7306fae --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowDown.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowDown.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowInbox.imageset/ArrowInbox.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowInbox.imageset/ArrowInbox.svg new file mode 100644 index 000000000..48110715f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowInbox.imageset/ArrowInbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowInbox.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowInbox.imageset/Contents.json new file mode 100644 index 000000000..e08ef5476 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowInbox.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowInbox.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowLeft.imageset/ArrowLeft.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowLeft.imageset/ArrowLeft.svg new file mode 100644 index 000000000..ac46537f7 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowLeft.imageset/ArrowLeft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowLeft.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowLeft.imageset/Contents.json new file mode 100644 index 000000000..3cad02669 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowLeft.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowLeft.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowRight.imageset/ArrowRight.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowRight.imageset/ArrowRight.svg new file mode 100644 index 000000000..3bec348b1 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowRight.imageset/ArrowRight.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowRight.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowRight.imageset/Contents.json new file mode 100644 index 000000000..682090e7e --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowRight.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowRight.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowRotateClockwise.imageset/ArrowRotateClockwise.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowRotateClockwise.imageset/ArrowRotateClockwise.svg new file mode 100644 index 000000000..130497bee --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowRotateClockwise.imageset/ArrowRotateClockwise.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowRotateClockwise.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowRotateClockwise.imageset/Contents.json new file mode 100644 index 000000000..1881016d6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowRotateClockwise.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowRotateClockwise.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareLeft.imageset/ArrowShareLeft.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareLeft.imageset/ArrowShareLeft.svg new file mode 100644 index 000000000..6e272ee83 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareLeft.imageset/ArrowShareLeft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareLeft.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareLeft.imageset/Contents.json new file mode 100644 index 000000000..f58c864e5 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareLeft.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowShareLeft.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareLeftFilled.imageset/ArrowShareLeftFilled.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareLeftFilled.imageset/ArrowShareLeftFilled.svg new file mode 100644 index 000000000..5e47db0b6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareLeftFilled.imageset/ArrowShareLeftFilled.svg @@ -0,0 +1 @@ + diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareLeftFilled.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareLeftFilled.imageset/Contents.json new file mode 100644 index 000000000..cac22ece9 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareLeftFilled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "ArrowShareLeftFilled.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareRight.imageset/ArrowShareRight.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareRight.imageset/ArrowShareRight.svg new file mode 100644 index 000000000..b12df3f11 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareRight.imageset/ArrowShareRight.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareRight.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareRight.imageset/Contents.json new file mode 100644 index 000000000..de4aa2a24 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareRight.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowShareRight.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowUp.imageset/ArrowUp.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowUp.imageset/ArrowUp.svg new file mode 100644 index 000000000..3e00b32f0 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowUp.imageset/ArrowUp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowUp.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowUp.imageset/Contents.json new file mode 100644 index 000000000..b9dd3e332 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowUp.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowUp.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowUpWall.imageset/ArrowUpWall.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowUpWall.imageset/ArrowUpWall.svg new file mode 100644 index 000000000..ef187d2cb --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowUpWall.imageset/ArrowUpWall.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowUpWall.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowUpWall.imageset/Contents.json new file mode 100644 index 000000000..484697e3f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowUpWall.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowUpWall.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowWallDown.imageset/ArrowWallDown.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowWallDown.imageset/ArrowWallDown.svg new file mode 100644 index 000000000..33dff87b0 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowWallDown.imageset/ArrowWallDown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowWallDown.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowWallDown.imageset/Contents.json new file mode 100644 index 000000000..05006297d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowWallDown.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowWallDown.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowsRepeat.imageset/ArrowsRepeat.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowsRepeat.imageset/ArrowsRepeat.svg new file mode 100644 index 000000000..b5cabb4d3 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowsRepeat.imageset/ArrowsRepeat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowsRepeat.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowsRepeat.imageset/Contents.json new file mode 100644 index 000000000..e56c3214a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowsRepeat.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ArrowsRepeat.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/At.imageset/At.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/At.imageset/At.svg new file mode 100644 index 000000000..1aa2f93db --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/At.imageset/At.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/At.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/At.imageset/Contents.json new file mode 100644 index 000000000..8ef9c2ec3 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/At.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "At.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Banknote1.imageset/Banknote1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Banknote1.imageset/Banknote1.svg new file mode 100644 index 000000000..0e8b59952 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Banknote1.imageset/Banknote1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Banknote1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Banknote1.imageset/Contents.json new file mode 100644 index 000000000..72c800346 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Banknote1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Banknote1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bell2.imageset/Bell2.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bell2.imageset/Bell2.svg new file mode 100644 index 000000000..41a4edc1a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bell2.imageset/Bell2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bell2.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bell2.imageset/Contents.json new file mode 100644 index 000000000..b9b1e25bf --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bell2.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Bell2.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bell2Off.imageset/Bell2Off.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bell2Off.imageset/Bell2Off.svg new file mode 100644 index 000000000..feb7ae554 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bell2Off.imageset/Bell2Off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bell2Off.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bell2Off.imageset/Contents.json new file mode 100644 index 000000000..2f73be60c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bell2Off.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Bell2Off.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BellOff.imageset/BellOff.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BellOff.imageset/BellOff.svg new file mode 100644 index 000000000..be59fa7e4 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BellOff.imageset/BellOff.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BellOff.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BellOff.imageset/Contents.json new file mode 100644 index 000000000..991906482 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BellOff.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "BellOff.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BezierCurve.imageset/BezierCurve.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BezierCurve.imageset/BezierCurve.svg new file mode 100644 index 000000000..144d9e3dd --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BezierCurve.imageset/BezierCurve.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BezierCurve.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BezierCurve.imageset/Contents.json new file mode 100644 index 000000000..b7b3cebad --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BezierCurve.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "BezierCurve.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bubble2.imageset/Bubble2.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bubble2.imageset/Bubble2.svg new file mode 100644 index 000000000..e156ffb5b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bubble2.imageset/Bubble2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bubble2.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bubble2.imageset/Contents.json new file mode 100644 index 000000000..e0c92800a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bubble2.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Bubble2.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BubbleAlert.imageset/BubbleAlert.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BubbleAlert.imageset/BubbleAlert.svg new file mode 100644 index 000000000..bd6911976 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BubbleAlert.imageset/BubbleAlert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BubbleAlert.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BubbleAlert.imageset/Contents.json new file mode 100644 index 000000000..4602cd66b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BubbleAlert.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "BubbleAlert.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BubbleCrossed.imageset/BubbleCrossed.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BubbleCrossed.imageset/BubbleCrossed.svg new file mode 100644 index 000000000..fa13c0fc5 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BubbleCrossed.imageset/BubbleCrossed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BubbleCrossed.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BubbleCrossed.imageset/Contents.json new file mode 100644 index 000000000..db874b1c2 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BubbleCrossed.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "BubbleCrossed.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bug.imageset/Bug.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bug.imageset/Bug.svg new file mode 100644 index 000000000..bf8c11641 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bug.imageset/Bug.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bug.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bug.imageset/Contents.json new file mode 100644 index 000000000..f34e5f815 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bug.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Bug.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Camera1.imageset/Camera1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Camera1.imageset/Camera1.svg new file mode 100644 index 000000000..e1d200f71 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Camera1.imageset/Camera1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Camera1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Camera1.imageset/Contents.json new file mode 100644 index 000000000..13533367a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Camera1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Camera1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChainLink4.imageset/ChainLink4.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChainLink4.imageset/ChainLink4.svg new file mode 100644 index 000000000..374716678 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChainLink4.imageset/ChainLink4.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChainLink4.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChainLink4.imageset/Contents.json new file mode 100644 index 000000000..2fe323e8c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChainLink4.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ChainLink4.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChatBubbles.imageset/ChatBubbles.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChatBubbles.imageset/ChatBubbles.svg new file mode 100644 index 000000000..814aa164f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChatBubbles.imageset/ChatBubbles.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChatBubbles.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChatBubbles.imageset/Contents.json new file mode 100644 index 000000000..0ab5226d6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChatBubbles.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ChatBubbles.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChatBubblesFilled.imageset/ChatBubblesFilled.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChatBubblesFilled.imageset/ChatBubblesFilled.svg new file mode 100644 index 000000000..9a3817cd7 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChatBubblesFilled.imageset/ChatBubblesFilled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChatBubblesFilled.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChatBubblesFilled.imageset/Contents.json new file mode 100644 index 000000000..b66467f8d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChatBubblesFilled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ChatBubblesFilled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Checklist.imageset/Checklist.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Checklist.imageset/Checklist.svg new file mode 100644 index 000000000..93d0131cf --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Checklist.imageset/Checklist.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Checklist.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Checklist.imageset/Contents.json new file mode 100644 index 000000000..61bf416b7 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Checklist.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Checklist.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Checkmark1.imageset/Checkmark1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Checkmark1.imageset/Checkmark1.svg new file mode 100644 index 000000000..02d4e447c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Checkmark1.imageset/Checkmark1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Checkmark1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Checkmark1.imageset/Contents.json new file mode 100644 index 000000000..f5d617cb2 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Checkmark1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Checkmark1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronDownMedium.imageset/ChevronDownMedium.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronDownMedium.imageset/ChevronDownMedium.svg new file mode 100644 index 000000000..43c3729d5 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronDownMedium.imageset/ChevronDownMedium.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronDownMedium.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronDownMedium.imageset/Contents.json new file mode 100644 index 000000000..8cb96f20b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronDownMedium.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ChevronDownMedium.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronLeftMedium.imageset/ChevronLeftMedium.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronLeftMedium.imageset/ChevronLeftMedium.svg new file mode 100644 index 000000000..6ce85b93c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronLeftMedium.imageset/ChevronLeftMedium.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronLeftMedium.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronLeftMedium.imageset/Contents.json new file mode 100644 index 000000000..3dbdf1b9e --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronLeftMedium.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ChevronLeftMedium.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronRightMedium.imageset/ChevronRightMedium.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronRightMedium.imageset/ChevronRightMedium.svg new file mode 100644 index 000000000..32649074b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronRightMedium.imageset/ChevronRightMedium.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronRightMedium.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronRightMedium.imageset/Contents.json new file mode 100644 index 000000000..303b618c9 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronRightMedium.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ChevronRightMedium.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleArrowDown.imageset/CircleArrowDown.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleArrowDown.imageset/CircleArrowDown.svg new file mode 100644 index 000000000..2548de9b8 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleArrowDown.imageset/CircleArrowDown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleArrowDown.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleArrowDown.imageset/Contents.json new file mode 100644 index 000000000..336a7fd15 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleArrowDown.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CircleArrowDown.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleBanSign.imageset/CircleBanSign.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleBanSign.imageset/CircleBanSign.svg new file mode 100644 index 000000000..2df2dce2d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleBanSign.imageset/CircleBanSign.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleBanSign.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleBanSign.imageset/Contents.json new file mode 100644 index 000000000..383bf6378 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleBanSign.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CircleBanSign.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleCheck.imageset/CircleCheck.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleCheck.imageset/CircleCheck.svg new file mode 100644 index 000000000..479f1eee6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleCheck.imageset/CircleCheck.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleCheck.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleCheck.imageset/Contents.json new file mode 100644 index 000000000..f8942d3c6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleCheck.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CircleCheck.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleDotted.imageset/CircleDotted.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleDotted.imageset/CircleDotted.svg new file mode 100644 index 000000000..71e4d4066 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleDotted.imageset/CircleDotted.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleDotted.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleDotted.imageset/Contents.json new file mode 100644 index 000000000..13a41e351 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleDotted.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CircleDotted.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleInfo.imageset/CircleInfo.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleInfo.imageset/CircleInfo.svg new file mode 100644 index 000000000..61cf3c96f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleInfo.imageset/CircleInfo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleInfo.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleInfo.imageset/Contents.json new file mode 100644 index 000000000..9c1bdf770 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleInfo.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CircleInfo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleMinus.imageset/CircleMinus.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleMinus.imageset/CircleMinus.svg new file mode 100644 index 000000000..37de34233 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleMinus.imageset/CircleMinus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleMinus.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleMinus.imageset/Contents.json new file mode 100644 index 000000000..7d8fc6a53 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleMinus.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CircleMinus.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CirclePerson.imageset/CirclePerson.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CirclePerson.imageset/CirclePerson.svg new file mode 100644 index 000000000..fd54a3279 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CirclePerson.imageset/CirclePerson.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CirclePerson.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CirclePerson.imageset/Contents.json new file mode 100644 index 000000000..f5b56aaa2 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CirclePerson.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CirclePerson.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CirclePlus.imageset/CirclePlus.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CirclePlus.imageset/CirclePlus.svg new file mode 100644 index 000000000..8175f72ba --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CirclePlus.imageset/CirclePlus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CirclePlus.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CirclePlus.imageset/Contents.json new file mode 100644 index 000000000..f0d71e5c6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CirclePlus.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CirclePlus.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleX.imageset/CircleX.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleX.imageset/CircleX.svg new file mode 100644 index 000000000..532e60be6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleX.imageset/CircleX.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleX.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleX.imageset/Contents.json new file mode 100644 index 000000000..326905b75 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleX.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CircleX.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleXFilled.imageset/CircleXFilled.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleXFilled.imageset/CircleXFilled.svg new file mode 100644 index 000000000..9a1d1f4ba --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleXFilled.imageset/CircleXFilled.svg @@ -0,0 +1 @@ + diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleXFilled.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleXFilled.imageset/Contents.json new file mode 100644 index 000000000..f5ea3073b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleXFilled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "CircleXFilled.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Clipboard.imageset/Clipboard.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Clipboard.imageset/Clipboard.svg new file mode 100644 index 000000000..72a83dda6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Clipboard.imageset/Clipboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Clipboard.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Clipboard.imageset/Contents.json new file mode 100644 index 000000000..db497ee42 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Clipboard.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Clipboard.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Clock.imageset/Clock.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Clock.imageset/Clock.svg new file mode 100644 index 000000000..58ed8b1aa --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Clock.imageset/Clock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Clock.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Clock.imageset/Contents.json new file mode 100644 index 000000000..7ea624f54 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Clock.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Clock.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Coins.imageset/Coins.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Coins.imageset/Coins.svg new file mode 100644 index 000000000..403871a21 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Coins.imageset/Coins.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Coins.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Coins.imageset/Contents.json new file mode 100644 index 000000000..e7c9ea255 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Coins.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Coins.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CoinsAdd.imageset/CoinsAdd.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CoinsAdd.imageset/CoinsAdd.svg new file mode 100644 index 000000000..53e960151 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CoinsAdd.imageset/CoinsAdd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CoinsAdd.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CoinsAdd.imageset/Contents.json new file mode 100644 index 000000000..b5885551f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CoinsAdd.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CoinsAdd.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CreditCard1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CreditCard1.imageset/Contents.json new file mode 100644 index 000000000..a1b081edd --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CreditCard1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CreditCard1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CreditCard1.imageset/CreditCard1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CreditCard1.imageset/CreditCard1.svg new file mode 100644 index 000000000..fc6221ef2 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CreditCard1.imageset/CreditCard1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CrossMedium.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CrossMedium.imageset/Contents.json new file mode 100644 index 000000000..16714a880 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CrossMedium.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CrossMedium.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CrossMedium.imageset/CrossMedium.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CrossMedium.imageset/CrossMedium.svg new file mode 100644 index 000000000..396418f7c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CrossMedium.imageset/CrossMedium.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Crown.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Crown.imageset/Contents.json new file mode 100644 index 000000000..33ab9ac69 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Crown.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Crown.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Crown.imageset/Crown.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Crown.imageset/Crown.svg new file mode 100644 index 000000000..3ed89ccdb --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Crown.imageset/Crown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/DotGrid1x3Horizontal.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/DotGrid1x3Horizontal.imageset/Contents.json new file mode 100644 index 000000000..700751126 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/DotGrid1x3Horizontal.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "DotGrid1x3Horizontal.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/DotGrid1x3Horizontal.imageset/DotGrid1x3Horizontal.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/DotGrid1x3Horizontal.imageset/DotGrid1x3Horizontal.svg new file mode 100644 index 000000000..5dcf04812 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/DotGrid1x3Horizontal.imageset/DotGrid1x3Horizontal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/DotGrid1x3Vertical.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/DotGrid1x3Vertical.imageset/Contents.json new file mode 100644 index 000000000..2b066bcdf --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/DotGrid1x3Vertical.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "DotGrid1x3Vertical.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/DotGrid1x3Vertical.imageset/DotGrid1x3Vertical.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/DotGrid1x3Vertical.imageset/DotGrid1x3Vertical.svg new file mode 100644 index 000000000..cc5c94458 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/DotGrid1x3Vertical.imageset/DotGrid1x3Vertical.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Email1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Email1.imageset/Contents.json new file mode 100644 index 000000000..3b0309757 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Email1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Email1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Email1.imageset/Email1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Email1.imageset/Email1.svg new file mode 100644 index 000000000..d72a7ad91 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Email1.imageset/Email1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/EmojiAddReaction.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/EmojiAddReaction.imageset/Contents.json new file mode 100644 index 000000000..058cd50b1 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/EmojiAddReaction.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "EmojiAddReaction.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/EmojiAddReaction.imageset/EmojiAddReaction.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/EmojiAddReaction.imageset/EmojiAddReaction.svg new file mode 100644 index 000000000..3661781ac --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/EmojiAddReaction.imageset/EmojiAddReaction.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ExclamationCircle.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ExclamationCircle.imageset/Contents.json new file mode 100644 index 000000000..2da3d3b33 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ExclamationCircle.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ExclamationCircle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ExclamationCircle.imageset/ExclamationCircle.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ExclamationCircle.imageset/ExclamationCircle.svg new file mode 100644 index 000000000..607097b4f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ExclamationCircle.imageset/ExclamationCircle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ExclamationTriangle.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ExclamationTriangle.imageset/Contents.json new file mode 100644 index 000000000..4439746eb --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ExclamationTriangle.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ExclamationTriangle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ExclamationTriangle.imageset/ExclamationTriangle.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ExclamationTriangle.imageset/ExclamationTriangle.svg new file mode 100644 index 000000000..0d57cfc28 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ExclamationTriangle.imageset/ExclamationTriangle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FaceId.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FaceId.imageset/Contents.json new file mode 100644 index 000000000..614fc3cb9 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FaceId.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "FaceId.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FaceId.imageset/FaceId.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FaceId.imageset/FaceId.svg new file mode 100644 index 000000000..894635339 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FaceId.imageset/FaceId.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileBend.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileBend.imageset/Contents.json new file mode 100644 index 000000000..4c81e6867 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileBend.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "FileBend.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileBend.imageset/FileBend.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileBend.imageset/FileBend.svg new file mode 100644 index 000000000..fd3be0cc7 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileBend.imageset/FileBend.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileDownload.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileDownload.imageset/Contents.json new file mode 100644 index 000000000..d90d5ca75 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileDownload.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "FileDownload.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileDownload.imageset/FileDownload.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileDownload.imageset/FileDownload.svg new file mode 100644 index 000000000..7401028c2 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileDownload.imageset/FileDownload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileText.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileText.imageset/Contents.json new file mode 100644 index 000000000..d192ec4b5 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileText.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "FileText.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileText.imageset/FileText.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileText.imageset/FileText.svg new file mode 100644 index 000000000..6f9432bd8 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileText.imageset/FileText.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FingerPrint1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FingerPrint1.imageset/Contents.json new file mode 100644 index 000000000..0531d0053 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FingerPrint1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "FingerPrint1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FingerPrint1.imageset/FingerPrint1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FingerPrint1.imageset/FingerPrint1.svg new file mode 100644 index 000000000..dafb4f86c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FingerPrint1.imageset/FingerPrint1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Flag1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Flag1.imageset/Contents.json new file mode 100644 index 000000000..a3c5c7f5b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Flag1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Flag1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Flag1.imageset/Flag1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Flag1.imageset/Flag1.svg new file mode 100644 index 000000000..7e5b23a08 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Flag1.imageset/Flag1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Folder1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Folder1.imageset/Contents.json new file mode 100644 index 000000000..7ec563250 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Folder1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Folder1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Folder1.imageset/Folder1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Folder1.imageset/Folder1.svg new file mode 100644 index 000000000..0cde89303 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Folder1.imageset/Folder1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Gift1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Gift1.imageset/Contents.json new file mode 100644 index 000000000..b192b0d2e --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Gift1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Gift1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Gift1.imageset/Gift1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Gift1.imageset/Gift1.svg new file mode 100644 index 000000000..f899d3003 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Gift1.imageset/Gift1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Globe.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Globe.imageset/Contents.json new file mode 100644 index 000000000..0a8ba3c96 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Globe.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Globe.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Globe.imageset/Globe.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Globe.imageset/Globe.svg new file mode 100644 index 000000000..0d8644ea7 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Globe.imageset/Globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Grid.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Grid.imageset/Contents.json new file mode 100644 index 000000000..e941936fe --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Grid.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Grid.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Grid.imageset/Grid.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Grid.imageset/Grid.svg new file mode 100644 index 000000000..3862b6597 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Grid.imageset/Grid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Group1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Group1.imageset/Contents.json new file mode 100644 index 000000000..c0b7c2b07 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Group1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Group1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Group1.imageset/Group1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Group1.imageset/Group1.svg new file mode 100644 index 000000000..067adde7d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Group1.imageset/Group1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Group3.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Group3.imageset/Contents.json new file mode 100644 index 000000000..d8f84079f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Group3.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Group3.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Group3.imageset/Group3.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Group3.imageset/Group3.svg new file mode 100644 index 000000000..c77afa016 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Group3.imageset/Group3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Hand5Finger.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Hand5Finger.imageset/Contents.json new file mode 100644 index 000000000..57febe68d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Hand5Finger.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Hand5Finger.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Hand5Finger.imageset/Hand5Finger.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Hand5Finger.imageset/Hand5Finger.svg new file mode 100644 index 000000000..f9cafa445 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Hand5Finger.imageset/Hand5Finger.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/History.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/History.imageset/Contents.json new file mode 100644 index 000000000..56b7404fd --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/History.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "History.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/History.imageset/History.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/History.imageset/History.svg new file mode 100644 index 000000000..4d7b1aa1c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/History.imageset/History.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Images1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Images1.imageset/Contents.json new file mode 100644 index 000000000..6ba2822f4 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Images1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Images1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Images1.imageset/Images1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Images1.imageset/Images1.svg new file mode 100644 index 000000000..6e403f722 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Images1.imageset/Images1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Images3.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Images3.imageset/Contents.json new file mode 100644 index 000000000..b5e5c8c8a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Images3.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Images3.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Images3.imageset/Images3.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Images3.imageset/Images3.svg new file mode 100644 index 000000000..310e54171 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Images3.imageset/Images3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Invite.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Invite.imageset/Contents.json new file mode 100644 index 000000000..43eca109a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Invite.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Invite.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Invite.imageset/Invite.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Invite.imageset/Invite.svg new file mode 100644 index 000000000..5793a6972 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Invite.imageset/Invite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Key1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Key1.imageset/Contents.json new file mode 100644 index 000000000..95409e372 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Key1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Key1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Key1.imageset/Key1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Key1.imageset/Key1.svg new file mode 100644 index 000000000..e437f2007 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Key1.imageset/Key1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/KikCode.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/KikCode.imageset/Contents.json new file mode 100644 index 000000000..b229774ad --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/KikCode.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "KikCode.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/KikCode.imageset/KikCode.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/KikCode.imageset/KikCode.svg new file mode 100644 index 000000000..abffef84a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/KikCode.imageset/KikCode.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/KikCodeFilled.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/KikCodeFilled.imageset/Contents.json new file mode 100644 index 000000000..bdbe8aefb --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/KikCodeFilled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "KikCodeFilled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/KikCodeFilled.imageset/KikCodeFilled.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/KikCodeFilled.imageset/KikCodeFilled.svg new file mode 100644 index 000000000..abffef84a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/KikCodeFilled.imageset/KikCodeFilled.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/LightningBolt.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/LightningBolt.imageset/Contents.json new file mode 100644 index 000000000..4e70ef75e --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/LightningBolt.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "LightningBolt.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/LightningBolt.imageset/LightningBolt.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/LightningBolt.imageset/LightningBolt.svg new file mode 100644 index 000000000..11ccd01c3 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/LightningBolt.imageset/LightningBolt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/LightningBoltOff.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/LightningBoltOff.imageset/Contents.json new file mode 100644 index 000000000..912df81cc --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/LightningBoltOff.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "LightningBoltOff.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/LightningBoltOff.imageset/LightningBoltOff.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/LightningBoltOff.imageset/LightningBoltOff.svg new file mode 100644 index 000000000..b948289d2 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/LightningBoltOff.imageset/LightningBoltOff.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Lock.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Lock.imageset/Contents.json new file mode 100644 index 000000000..2a8b29c95 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Lock.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Lock.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Lock.imageset/Lock.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Lock.imageset/Lock.svg new file mode 100644 index 000000000..1aed50fbd --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Lock.imageset/Lock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/MagnifyingGlass.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/MagnifyingGlass.imageset/Contents.json new file mode 100644 index 000000000..e54f05332 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/MagnifyingGlass.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "MagnifyingGlass.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/MagnifyingGlass.imageset/MagnifyingGlass.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/MagnifyingGlass.imageset/MagnifyingGlass.svg new file mode 100644 index 000000000..dca06736b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/MagnifyingGlass.imageset/MagnifyingGlass.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Maintenance.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Maintenance.imageset/Contents.json new file mode 100644 index 000000000..98dbf2857 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Maintenance.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Maintenance.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Maintenance.imageset/Maintenance.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Maintenance.imageset/Maintenance.svg new file mode 100644 index 000000000..614ff2b97 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Maintenance.imageset/Maintenance.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PaperPlaneTopRight.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PaperPlaneTopRight.imageset/Contents.json new file mode 100644 index 000000000..fa298b679 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PaperPlaneTopRight.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "PaperPlaneTopRight.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PaperPlaneTopRight.imageset/PaperPlaneTopRight.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PaperPlaneTopRight.imageset/PaperPlaneTopRight.svg new file mode 100644 index 000000000..2ed813311 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PaperPlaneTopRight.imageset/PaperPlaneTopRight.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Pencil2.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Pencil2.imageset/Contents.json new file mode 100644 index 000000000..8cd12b9c3 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Pencil2.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Pencil2.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Pencil2.imageset/Pencil2.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Pencil2.imageset/Pencil2.svg new file mode 100644 index 000000000..7b6282896 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Pencil2.imageset/Pencil2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/People.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/People.imageset/Contents.json new file mode 100644 index 000000000..cc3b33102 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/People.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "People.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/People.imageset/People.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/People.imageset/People.svg new file mode 100644 index 000000000..e06de4e6e --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/People.imageset/People.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleCircle.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleCircle.imageset/Contents.json new file mode 100644 index 000000000..06ce42047 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleCircle.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "PeopleCircle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleCircle.imageset/PeopleCircle.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleCircle.imageset/PeopleCircle.svg new file mode 100644 index 000000000..aa85bd932 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleCircle.imageset/PeopleCircle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleCircleFilled.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleCircleFilled.imageset/Contents.json new file mode 100644 index 000000000..bf46153b9 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleCircleFilled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "PeopleCircleFilled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleCircleFilled.imageset/PeopleCircleFilled.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleCircleFilled.imageset/PeopleCircleFilled.svg new file mode 100644 index 000000000..ef3ac822c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleCircleFilled.imageset/PeopleCircleFilled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleGear.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleGear.imageset/Contents.json new file mode 100644 index 000000000..22c640ee5 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleGear.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "PeopleGear.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleGear.imageset/PeopleGear.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleGear.imageset/PeopleGear.svg new file mode 100644 index 000000000..13a211ea4 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleGear.imageset/PeopleGear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleIdCard.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleIdCard.imageset/Contents.json new file mode 100644 index 000000000..9e012e4c6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleIdCard.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "PeopleIdCard.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleIdCard.imageset/PeopleIdCard.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleIdCard.imageset/PeopleIdCard.svg new file mode 100644 index 000000000..6cdb7b329 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleIdCard.imageset/PeopleIdCard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Phone.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Phone.imageset/Contents.json new file mode 100644 index 000000000..0630d4dff --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Phone.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Phone.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Phone.imageset/Phone.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Phone.imageset/Phone.svg new file mode 100644 index 000000000..135a2805a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Phone.imageset/Phone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PlusLarge.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PlusLarge.imageset/Contents.json new file mode 100644 index 000000000..167280cb3 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PlusLarge.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "PlusLarge.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PlusLarge.imageset/PlusLarge.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PlusLarge.imageset/PlusLarge.svg new file mode 100644 index 000000000..3a77f9da5 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PlusLarge.imageset/PlusLarge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/QrCode.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/QrCode.imageset/Contents.json new file mode 100644 index 000000000..e99d58405 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/QrCode.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "QrCode.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/QrCode.imageset/QrCode.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/QrCode.imageset/QrCode.svg new file mode 100644 index 000000000..9b40f09a8 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/QrCode.imageset/QrCode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/QuestionmarkCircle.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/QuestionmarkCircle.imageset/Contents.json new file mode 100644 index 000000000..2e9d65f35 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/QuestionmarkCircle.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "QuestionmarkCircle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/QuestionmarkCircle.imageset/QuestionmarkCircle.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/QuestionmarkCircle.imageset/QuestionmarkCircle.svg new file mode 100644 index 000000000..dee98f8f1 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/QuestionmarkCircle.imageset/QuestionmarkCircle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Send.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Send.imageset/Contents.json new file mode 100644 index 000000000..ce8a894d2 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Send.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Send.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Send.imageset/Send.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Send.imageset/Send.svg new file mode 100644 index 000000000..c3b127974 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Send.imageset/Send.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SettingsGear2.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SettingsGear2.imageset/Contents.json new file mode 100644 index 000000000..b5a056200 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SettingsGear2.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "SettingsGear2.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SettingsGear2.imageset/SettingsGear2.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SettingsGear2.imageset/SettingsGear2.svg new file mode 100644 index 000000000..2ae879f02 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SettingsGear2.imageset/SettingsGear2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SettingsSliderThree.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SettingsSliderThree.imageset/Contents.json new file mode 100644 index 000000000..797ec2213 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SettingsSliderThree.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "SettingsSliderThree.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SettingsSliderThree.imageset/SettingsSliderThree.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SettingsSliderThree.imageset/SettingsSliderThree.svg new file mode 100644 index 000000000..4d4f6c13a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SettingsSliderThree.imageset/SettingsSliderThree.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ShareOs.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ShareOs.imageset/Contents.json new file mode 100644 index 000000000..311e9364c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ShareOs.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ShareOs.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ShareOs.imageset/ShareOs.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ShareOs.imageset/ShareOs.svg new file mode 100644 index 000000000..9f0756f23 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ShareOs.imageset/ShareOs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SquareBehindSquare1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SquareBehindSquare1.imageset/Contents.json new file mode 100644 index 000000000..964cf5216 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SquareBehindSquare1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "SquareBehindSquare1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SquareBehindSquare1.imageset/SquareBehindSquare1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SquareBehindSquare1.imageset/SquareBehindSquare1.svg new file mode 100644 index 000000000..a91535b7a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SquareBehindSquare1.imageset/SquareBehindSquare1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SquareX.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SquareX.imageset/Contents.json new file mode 100644 index 000000000..854e59dac --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SquareX.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "SquareX.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SquareX.imageset/SquareX.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SquareX.imageset/SquareX.svg new file mode 100644 index 000000000..9dc8db5e9 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SquareX.imageset/SquareX.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/TextSize.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/TextSize.imageset/Contents.json new file mode 100644 index 000000000..578874f73 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/TextSize.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "TextSize.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/TextSize.imageset/TextSize.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/TextSize.imageset/TextSize.svg new file mode 100644 index 000000000..d5548fe81 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/TextSize.imageset/TextSize.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/TrashCan.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/TrashCan.imageset/Contents.json new file mode 100644 index 000000000..7130060d2 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/TrashCan.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "TrashCan.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/TrashCan.imageset/TrashCan.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/TrashCan.imageset/TrashCan.svg new file mode 100644 index 000000000..d5137ee9c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/TrashCan.imageset/TrashCan.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Trophy.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Trophy.imageset/Contents.json new file mode 100644 index 000000000..f98884329 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Trophy.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Trophy.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Trophy.imageset/Trophy.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Trophy.imageset/Trophy.svg new file mode 100644 index 000000000..183af0717 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Trophy.imageset/Trophy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/User.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/User.imageset/Contents.json new file mode 100644 index 000000000..5b0c4b95d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/User.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "User.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/User.imageset/User.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/User.imageset/User.svg new file mode 100644 index 000000000..7ca28d46b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/User.imageset/User.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/UserAdd.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/UserAdd.imageset/Contents.json new file mode 100644 index 000000000..0e991b686 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/UserAdd.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "UserAdd.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/UserAdd.imageset/UserAdd.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/UserAdd.imageset/UserAdd.svg new file mode 100644 index 000000000..61db90567 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/UserAdd.imageset/UserAdd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/VolumeOff.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/VolumeOff.imageset/Contents.json new file mode 100644 index 000000000..e2ff371d3 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/VolumeOff.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "VolumeOff.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/VolumeOff.imageset/VolumeOff.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/VolumeOff.imageset/VolumeOff.svg new file mode 100644 index 000000000..1a488cec8 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/VolumeOff.imageset/VolumeOff.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet1.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet1.imageset/Contents.json new file mode 100644 index 000000000..33031b43c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet1.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Wallet1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet1.imageset/Wallet1.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet1.imageset/Wallet1.svg new file mode 100644 index 000000000..9dfbe5093 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet1.imageset/Wallet1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet5.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet5.imageset/Contents.json new file mode 100644 index 000000000..d756366ef --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet5.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Wallet5.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet5.imageset/Wallet5.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet5.imageset/Wallet5.svg new file mode 100644 index 000000000..a65272c15 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet5.imageset/Wallet5.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet5Filled.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet5Filled.imageset/Contents.json new file mode 100644 index 000000000..b21a85821 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet5Filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Wallet5Filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet5Filled.imageset/Wallet5Filled.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet5Filled.imageset/Wallet5Filled.svg new file mode 100644 index 000000000..cb581897a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet5Filled.imageset/Wallet5Filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconLock.imageset/IconLock.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconLock.imageset/IconLock.svg index 162b3ed51..1aed50fbd 100644 --- a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconLock.imageset/IconLock.svg +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconLock.imageset/IconLock.svg @@ -1,3 +1,4 @@ - + + diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift index e4ba7615e..aade09d0d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift @@ -20,7 +20,7 @@ import SwiftUI /// /// A bubble run flattens the inner corners /// from `baseRadius` to `groupedRadius`, which UIKit's `cornerCurve`/`maskedCorners` can't express, -/// so the path is taken straight from SwiftUI's `UnevenRoundedRectangle(.continuous)` (pure geometry, +/// so the path is taken straight from SwiftUI's `UnevenRoundedRectangle` (pure geometry, /// no hosted SwiftUI views) and drawn into a `CAShapeLayer`. final class BubbleBackgroundView: UIView { @@ -40,8 +40,8 @@ final class BubbleBackgroundView: UIView { } /// Base corner radius; the inner corner of a grouped run uses `groupedRadius`. - static let baseRadius: CGFloat = 12 - static let groupedRadius: CGFloat = 4 + static var baseRadius: CGFloat { ChatStyle.bubbleRadius } + static var groupedRadius: CGFloat { ChatStyle.joinedRadius } private let shapeMask = CAShapeLayer() private let washLayer = CALayer() @@ -109,6 +109,7 @@ final class BubbleBackgroundView: UIView { switch border { case .hairline: washLayer.isHidden = bare + borderLayer.isHidden = bare || !ChatStyle.drawsHairline borderLayer.strokeColor = Self.hairlineStroke.cgColor borderLayer.lineDashPattern = nil case .dashed: @@ -135,7 +136,7 @@ final class BubbleBackgroundView: UIView { /// Whether this chrome draws a bubble: the opaque base, the wash and the hairline border. False /// for a bare row, which keeps only the shape mask and the attention layer — the mask because /// an unclipped flash would be a rectangle floating where no bubble is. - var isDrawingBubble: Bool { !borderLayer.isHidden } + var isDrawingBubble: Bool { !washLayer.isHidden || !borderLayer.isHidden } /// The bubble's continuous, per-corner rounded shape in its own coordinate space — the same /// geometry used for the layer mask. Clips the context-menu lift preview to the bubble. @@ -175,10 +176,7 @@ final class BubbleBackgroundView: UIView { func grow(from rect: CGRect, on spring: ChatSpring) { layoutIfNeeded() guard let end = shapeMask.path else { return } - let start = UnevenRoundedRectangle( - cornerRadii: Self.radii(isFromSelf: false, groupedAbove: false, groupedBelow: false), - style: .continuous - ).path(in: rect).cgPath + let start = Self.path(radii: Self.radii(isFromSelf: false, groupedAbove: false, groupedBelow: false), in: rect) for layer in [shapeMask, borderLayer] { layer.add(spring.layerAnimation(keyPath: "path", from: start, to: end), forKey: Self.growKey) } @@ -280,20 +278,30 @@ final class BubbleBackgroundView: UIView { view.layer.shadowPath = nil } - /// White-opacity wash for a sender, composited over the conversation background by `apply`. + /// A sender's bubble colour, composited over the conversation background by `apply`. static func fill(isFromSelf: Bool) -> UIColor { - isFromSelf - ? UIColor.white.withAlphaComponent(0.10) - : UIColor.white.withAlphaComponent(0.05) + ChatStyle.fill(isFromSelf: isFromSelf) } /// Every corner at `baseRadius`: a bubble, or a link card, that stands alone. - static let standaloneRadii = RectangleCornerRadii(topLeading: baseRadius, bottomLeading: baseRadius, bottomTrailing: baseRadius, topTrailing: baseRadius) + static var standaloneRadii: RectangleCornerRadii { RectangleCornerRadii(topLeading: baseRadius, bottomLeading: baseRadius, bottomTrailing: baseRadius, topTrailing: baseRadius) } - /// The continuous, per-corner rounded outline of `rect`. The one shape every bubble and link - /// card in a run is cut to, so a card groups with the text around it exactly as a bubble does. + /// The per-corner rounded outline of `rect`. The one shape every bubble and link card in a run + /// is cut to, so a card groups with the text around it exactly as a bubble does. + /// + /// No corner grows past the capsule radius, half the shorter side, so a one-line bubble's round + /// end is a true semicircle and a round corner beside a joined one is a quarter of it. Left + /// unclamped, a corner bigger than half the height spends the edge its joined neighbour leaves + /// free and swells into a long, flat curve. static func path(radii: RectangleCornerRadii, in rect: CGRect) -> CGPath { - UnevenRoundedRectangle(cornerRadii: radii, style: .continuous).path(in: rect).cgPath + let capsule = min(rect.width, rect.height) / 2 + let clamped = RectangleCornerRadii( + topLeading: min(radii.topLeading, capsule), + bottomLeading: min(radii.bottomLeading, capsule), + bottomTrailing: min(radii.bottomTrailing, capsule), + topTrailing: min(radii.topTrailing, capsule) + ) + return UnevenRoundedRectangle(cornerRadii: clamped, style: ChatStyle.cornerStyle).path(in: rect).cgPath } /// Per-corner radii: a bubble run flattens the inner corners (nearest the avatar column) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatAuthorAvatarView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatAuthorAvatarView.swift index a636181c2..092169db7 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatAuthorAvatarView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatAuthorAvatarView.swift @@ -10,7 +10,7 @@ import UIKit import SwiftUI import FlipcashCore -/// The 24pt circle in the leading gutter of an attributed transcript row, drawn in UIKit. +/// The small circle in the leading gutter of an attributed transcript row, drawn in UIKit. /// /// Deliberately not a `UIHostingController` around ``ContactAvatarView``: the transcript recycles /// its cells, and hosting a SwiftUI view per recycled row costs a view-controller adoption on every @@ -19,8 +19,8 @@ import FlipcashCore /// process-wide caches, so an avatar decoded for a contact list is a hit here. final class ChatAuthorAvatarView: UIView { - /// Figma sizes the gutter circle at 24pt (nodes 10125:19169-19184). - static let size: CGFloat = 24 + /// The gutter circle's diameter, a step up from Figma's 24pt (nodes 10125:19169-19184). + static let size = ChatStyle.authorAvatarSize private let imageView = UIImageView() private let monogram = UILabel() @@ -31,6 +31,8 @@ final class ChatAuthorAvatarView: UIView { layer.cornerRadius = Self.size / 2 layer.masksToBounds = true + layer.borderWidth = ChatStyle.avatarStrokeWidth + layer.borderColor = UIColor.white.withAlphaComponent(ChatStyle.avatarStrokeOpacity).cgColor // The same two stops as `LinearGradient.avatarPlaceholder`, so a monogram in the transcript // matches one in a contact list. @@ -93,7 +95,7 @@ final class ChatAuthorAvatarView: UIView { monogram.text = text case .placeholder: // No letters to work with — the gradient alone stands in, rather than the people glyph - // the full-size avatar draws, which is illegible at 24pt. + // the full-size avatar draws, which is illegible at this size. monogram.text = nil } isAccessibilityElement = true diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdrop.swift index e98642acf..4d4a4874b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdrop.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdrop.swift @@ -13,41 +13,46 @@ import UIKit /// /// One definition because it is one effect on screen: the gate blurs the same bubbles the long /// press does, for a different reason, and anyone who meets both in a session sees any drift -/// between them. A recipe rather than a view, because the callers own their layers differently — -/// ``MessageBackdrop`` moves its blur between hosts mid-interaction, ``TranscriptBlur`` pins one to -/// a screen for its lifetime. +/// between them. They share the material and the dim and differ only in strength, because the +/// gate's job is to withhold. A recipe rather than a view, because the callers own their layers +/// differently — ``MessageBackdrop`` moves its blur between hosts mid-interaction, +/// ``TranscriptBlur`` pins one to a screen for its lifetime. @MainActor enum ChatBackdrop { - /// How much of the material's blur is used. A `UIBlurEffect` has no radius to set — every style - /// is the same radius under a different tint — so the effect is applied through an animator that - /// is paused part-way, which is the only handle on its strength. At full strength the transcript - /// smears into flat colour; Android frosts the same screen at a 25dp radius and reads far softer, - /// and this is matched to that. - static let blurFraction: CGFloat = 0.25 + /// How much of the material's blur is used behind a lifted message, its edit, and the + /// payment-request deck: lighter than the gate's. + /// + /// A `UIBlurEffect` has no radius to set — every style is the same radius under a different + /// tint — so the effect is applied through an animator that is paused part-way, which is the + /// only handle on its strength. At full strength the transcript smears into flat colour. + static let blurFraction: CGFloat = 0.1 + + /// How much of the material's blur is used over a transcript the viewer is not allowed to read. + /// Kept apart from ``blurFraction`` so a lighter decorative frost never makes withheld messages + /// legible; Android frosts the same screen at a 25dp radius, and this is matched to that. + static let gateBlurFraction: CGFloat = 0.25 - /// The black that goes with the blur wherever nothing else is darkening the screen. + /// The black that goes with the blur everywhere it is used, under a context menu's own dimming + /// too. /// /// The material is a translucent *light* veil, so over a near-black transcript it raises the /// black level — blur on its own hands back something paler than the sharp transcript beside it. - /// Under a context menu UIKit's own dimming covers that: measured on the same patch of empty - /// transcript, rgb 23 under the menu against 44 after it. Everywhere else this stands in for it, - /// set well below the value that matches the menu exactly — matching it left an edit as dark as - /// the menu, which is heavier than Android's frosting of the same screen, and the menu's own - /// dimming isn't ours to lighten to meet it. - static let dimAlpha: CGFloat = 0.2 + /// A long press raises this with the blur on its first frame, under the menu as well, and an + /// edit keeps it once the menu has gone. + static let dimAlpha: CGFloat = 0.33 - /// Applies the material to `view` at ``blurFraction``, and hands back the animator holding it - /// there. + /// Applies the material to `view` at `fraction` of its strength, ``blurFraction`` unless the + /// caller says otherwise, and hands back the animator holding it there. /// /// The animator is a dial, not an animation: it is never played out, but it has to be kept alive /// for the effect to stay applied, and stopped by hand before it deallocates — a property /// animator still active when it goes traps. Teardown belongs to the caller, which is the only /// side that knows when its blur is finished with. - static func frost(_ view: UIVisualEffectView) -> UIViewPropertyAnimator { + static func frost(_ view: UIVisualEffectView, fraction: CGFloat = blurFraction) -> UIViewPropertyAnimator { let material = UIBlurEffect(style: .systemUltraThinMaterialDark) let strength = UIViewPropertyAnimator(duration: 1, curve: .linear) { view.effect = material } - strength.fractionComplete = blurFraction + strength.fractionComplete = fraction return strength } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdropView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdropView.swift index a804e7408..f371ebc40 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdropView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdropView.swift @@ -40,7 +40,7 @@ public struct ChatBackdropView: UIViewRepresentable { public func makeCoordinator() -> Coordinator { Coordinator() } /// Holds the blur at ``ChatBackdrop/blurFraction`` until the view is dismantled — see - /// ``ChatBackdrop/frost(_:)``. + /// ``ChatBackdrop/frost(_:fraction:)``. @MainActor public final class Coordinator { var strength: UIViewPropertyAnimator? diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift index c1bbf5ead..d448c65fa 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift @@ -71,8 +71,8 @@ public final class ChatBubbleView: UIView { /// a bubble that is not drawn. private var isBare = false - private static let bodyInset: CGFloat = 12 - private static let bodyPadding: CGFloat = 9 + private static var bodyInset: CGFloat { ChatStyle.bubblePaddingX } + private static var bodyPadding: CGFloat { ChatStyle.bubblePaddingY } private static let hintTopSpacing: CGFloat = 8 private static let hintLeadingInset: CGFloat = 4 /// The update bubble's insets and spacing, from Figma component 11420:13397. It stacks a title, @@ -220,6 +220,7 @@ public final class ChatBubbleView: UIView { label.attributedText = Self.displayText(for: message) // Read after the body, so VoiceOver gets the send time without the reveal drag. label.accessibilityValue = message.sentAt?.formattedChatMessageTime() + editedLabel.textColor = ChatStyle.mutedText(isFromSelf: message.sender == .me) editedLabel.isHidden = !Self.showsEditedMarker(for: message) || message.rendersAsLargeEmoji isBare = message.rendersAsLargeEmoji @@ -271,7 +272,7 @@ public final class ChatBubbleView: UIView { // unsatisfiable, and UIKit resolves that by breaking one at random. if let quote = message.quote { quotePanel.isHidden = false - quotePanel.configure(with: quote, thumbnail: quoteThumbnail) + quotePanel.configure(with: quote, thumbnail: quoteThumbnail, onSent: message.sender == .me) NSLayoutConstraint.deactivate(quoteCollapse) quoteTrailing.isActive = true labelTopToBubble.isActive = false @@ -323,9 +324,8 @@ public final class ChatBubbleView: UIView { private static func unavailableText() -> NSAttributedString { let color = UIColor.white.withAlphaComponent(0.55) let font = UIFont.default(size: 15, weight: .medium) - let symbol = UIImage.SymbolConfiguration(pointSize: 14, weight: .regular) let attachment = NSTextAttachment() - attachment.image = UIImage(systemName: "exclamationmark.circle", withConfiguration: symbol)? + attachment.image = UIImage.icon(.exclamationCircle) .withTintColor(color, renderingMode: .alwaysOriginal) attachment.bounds = CGRect(x: 0, y: -2, width: 16, height: 16) @@ -375,19 +375,21 @@ public final class ChatBubbleView: UIView { return nil } + let isFromSelf = message.sender == .me let bodyFont: UIFont = if isPlaceholder { - .italicSystemFont(ofSize: 16) + .italicSystemFont(ofSize: ChatStyle.bodySize) } else if message.rendersAsLargeEmoji { .default(size: 48, weight: .medium) } else { - .default(size: 16, weight: .medium) + ChatStyle.bodyFont } - let bodyColor: UIColor = isPlaceholder ? UIColor.white.withAlphaComponent(0.55) : .white + let bodyColor = isPlaceholder ? ChatStyle.mutedText(isFromSelf: isFromSelf) : ChatStyle.text(isFromSelf: isFromSelf) - let result = NSMutableAttributedString( - string: body, - attributes: [.font: bodyFont, .foregroundColor: bodyColor] - ) + var attributes: [NSAttributedString.Key: Any] = [.font: bodyFont, .foregroundColor: bodyColor] + if !message.rendersAsLargeEmoji { + attributes.merge(ChatStyle.bodyLineAttributes(for: bodyFont)) { _, new in new } + } + let result = NSMutableAttributedString(string: body, attributes: attributes) if Self.showsEditedMarker(for: message), !message.rendersAsLargeEmoji { result.append(EditedMarker.reservation) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift index 5f0e6bf39..19115151e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift @@ -7,12 +7,14 @@ #if canImport(UIKit) import UIKit +import SwiftUI import FlipcashCore import Kingfisher -/// A recycled cell for a cash payment row: a card (shared `BubbleBackgroundView` chrome) with the -/// token name + optional coin icon in the top-left and a centered "You sent / You received" caption -/// above the amount, hosted in a `ChatColumnCell`. The card's width is supplied by +/// A recycled cell for a cash payment row: a neutral card (shared `BubbleBackgroundView` chrome) +/// with the token's badge and name in a header and a centered "You sent / You received" caption +/// above the amount, hosted in a `ChatColumnCell`. A group send drops the header for a token chip +/// under the amount and who was paid at its foot. The card's width is supplied by /// the owner, as `ChatMessageCell`/`ChatLinkMessageCell` take theirs, so it tracks the transcript /// width instead of a fixed footprint. `configure(with:maxWidth:)` is the only input; the strings, /// and icon URL all arrive on the `ChatMessage`. @@ -20,30 +22,35 @@ public final class ChatCashCardCell: ChatColumnCell { public static let reuseIdentifier = "ChatCashCardCell" - /// A cash card's fixed height; a group card sizes to its lines instead. Width tracks `maxWidth`. - static let cardHeight: CGFloat = 170 - private let card = BubbleBackgroundView() private let reactionRow = ReactionPillRowView() private let coinIcon = UIImageView() private let tokenLabel = UILabel() + private let tokenRow = UIStackView() private let captionLabel = UILabel() private let amountLabel = UILabel() private let eachLabel = UILabel() // A card in a group carries its token under the amount and who was paid at its foot, instead - // of the token in the top-left corner. - private let tokenRow = UIStackView() + // of the token in the header. private let centerCoinIcon = UIImageView() private let centerTokenLabel = UILabel() private let centerTokenRow = UIStackView() private let recipientsLabel = UILabel() private let centerStack = UIStackView() private var cardWidthConstraint: NSLayoutConstraint! - // A cash card is a fixed height with its lines centred; a group card's four or five lines size - // it instead, so each set is active for one kind only. + // A cash card hangs its lines under the header; a group card has no header and closes on its + // recipients line, so each set is active for one kind only. private var cashCardConstraints: [NSLayoutConstraint] = [] private var groupCardConstraints: [NSLayoutConstraint] = [] private var reactionRowWidthConstraint: NSLayoutConstraint! + private var badgeSize: [NSLayoutConstraint] = [] + private var headerTop: NSLayoutConstraint! + private var headerLeading: NSLayoutConstraint! + private var bodyTop: NSLayoutConstraint! + private var bodyBottom: NSLayoutConstraint! + private var groupBodyTop: NSLayoutConstraint! + private var recipientsTop: NSLayoutConstraint! + private var recipientsBottom: NSLayoutConstraint! /// Dim the card while pressed so the tap-to-open-currency-info affordance reads as a button. /// Driven by the collection view's selection machinery (`shouldHighlightItemAt`), not a gesture. @@ -61,40 +68,32 @@ public final class ChatCashCardCell: ChatColumnCell { card.translatesAutoresizingMaskIntoConstraints = false - configureCircle(coinIcon, diameter: 13) - tokenLabel.font = .default(size: 12, weight: .bold) - tokenLabel.textColor = UIColor.white.withAlphaComponent(0.5) + badgeSize = configureCircle(coinIcon) + badgeSize += configureCircle(centerCoinIcon) + tokenLabel.textColor = ChatStyle.secondaryText - tokenRow.addArrangedSubview(coinIcon) - tokenRow.addArrangedSubview(tokenLabel) - tokenRow.spacing = 4 + [coinIcon, tokenLabel].forEach(tokenRow.addArrangedSubview) + tokenRow.spacing = 6 tokenRow.alignment = .center tokenRow.translatesAutoresizingMaskIntoConstraints = false card.addSubview(tokenRow) - captionLabel.font = .default(size: 12, weight: .bold) - captionLabel.textColor = UIColor.white.withAlphaComponent(0.5) + captionLabel.textColor = ChatStyle.secondaryText - amountLabel.font = .default(size: 38, weight: .bold) - amountLabel.textColor = .white + amountLabel.textColor = ChatStyle.receivedText amountLabel.numberOfLines = 1 amountLabel.adjustsFontSizeToFitWidth = true amountLabel.minimumScaleFactor = 0.5 amountLabel.setContentCompressionResistancePriority(.required, for: .horizontal) - eachLabel.font = .default(size: 12, weight: .bold) - eachLabel.textColor = UIColor.white.withAlphaComponent(0.5) + eachLabel.textColor = ChatStyle.secondaryText - configureCircle(centerCoinIcon, diameter: 20) - centerTokenLabel.font = .default(size: 12, weight: .bold) - centerTokenLabel.textColor = .white - centerTokenRow.addArrangedSubview(centerCoinIcon) - centerTokenRow.addArrangedSubview(centerTokenLabel) + centerTokenLabel.textColor = ChatStyle.secondaryText + [centerCoinIcon, centerTokenLabel].forEach(centerTokenRow.addArrangedSubview) centerTokenRow.spacing = 6 centerTokenRow.alignment = .center - recipientsLabel.font = .default(size: 12, weight: .bold) - recipientsLabel.textColor = UIColor.white.withAlphaComponent(0.5) + recipientsLabel.textColor = ChatStyle.secondaryText recipientsLabel.lineBreakMode = .byTruncatingMiddle recipientsLabel.translatesAutoresizingMaskIntoConstraints = false card.addSubview(recipientsLabel) @@ -112,40 +111,39 @@ public final class ChatCashCardCell: ChatColumnCell { reactionRow.onAdd = { [weak self] in self?.onReactionAdd?() } reactionRow.onBoost = { [weak self] in self?.onReactionBoost?() } - // Below required so the card height yields to the cell's self-sizing height instead of fighting it. - let cardHeight = card.heightAnchor.constraint(equalToConstant: Self.cardHeight) - cardHeight.priority = UILayoutPriority(999) - - cashCardConstraints = [ - cardHeight, - centerStack.centerYAnchor.constraint(equalTo: card.centerYAnchor), - ] - groupCardConstraints = [ - centerStack.topAnchor.constraint(equalTo: card.topAnchor, constant: 24), - recipientsLabel.topAnchor.constraint(equalTo: centerStack.bottomAnchor, constant: 24), - ] - - cardWidthConstraint = card.widthAnchor.constraint(equalToConstant: 232) - reactionRowWidthConstraint = reactionRow.widthAnchor.constraint(equalToConstant: 232) + cardWidthConstraint = card.widthAnchor.constraint(equalToConstant: 240) + reactionRowWidthConstraint = reactionRow.widthAnchor.constraint(equalToConstant: 240) + headerTop = tokenRow.topAnchor.constraint(equalTo: card.topAnchor) + headerLeading = tokenRow.leadingAnchor.constraint(equalTo: card.leadingAnchor) + bodyTop = centerStack.topAnchor.constraint(equalTo: tokenRow.bottomAnchor) + // Below required so the card's height yields to the cell's self-sizing pass instead of fighting it. + bodyBottom = centerStack.bottomAnchor.constraint(equalTo: card.bottomAnchor) + bodyBottom.priority = UILayoutPriority(999) + groupBodyTop = centerStack.topAnchor.constraint(equalTo: card.topAnchor) + recipientsTop = recipientsLabel.topAnchor.constraint(equalTo: centerStack.bottomAnchor) + // Stays on for a cash card too, so the hidden recipients line has a resolved position. + recipientsBottom = recipientsLabel.bottomAnchor.constraint(equalTo: card.bottomAnchor) + recipientsBottom.priority = UILayoutPriority(999) + + cashCardConstraints = [bodyTop, bodyBottom] + groupCardConstraints = [groupBodyTop, recipientsTop] NSLayoutConstraint.activate([ cardWidthConstraint, reactionRowWidthConstraint, - - tokenRow.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 11), - tokenRow.topAnchor.constraint(equalTo: card.topAnchor, constant: 8), - + headerTop, + headerLeading, + tokenRow.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor), centerStack.centerXAnchor.constraint(equalTo: card.centerXAnchor), - centerStack.topAnchor.constraint(greaterThanOrEqualTo: card.topAnchor, constant: 8), - + centerStack.leadingAnchor.constraint(greaterThanOrEqualTo: card.leadingAnchor, constant: 16), + centerStack.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -16), + recipientsBottom, recipientsLabel.centerXAnchor.constraint(equalTo: card.centerXAnchor), - recipientsLabel.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -16), recipientsLabel.leadingAnchor.constraint(greaterThanOrEqualTo: card.leadingAnchor, constant: 16), recipientsLabel.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -16), - centerStack.leadingAnchor.constraint(greaterThanOrEqualTo: card.leadingAnchor, constant: 16), - centerStack.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -16), ]) NSLayoutConstraint.activate(cashCardConstraints) + applyMetrics() } @available(*, unavailable) @@ -168,15 +166,41 @@ public final class ChatCashCardCell: ChatColumnCell { /// Called when the viewer taps the leading "$" to pay for a reaction. var onReactionBoost: (() -> Void)? - private func configureCircle(_ imageView: UIImageView, diameter: CGFloat) { - imageView.contentMode = .scaleAspectFill - imageView.clipsToBounds = true - imageView.layer.cornerRadius = diameter / 2 - imageView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - imageView.widthAnchor.constraint(equalToConstant: diameter), - imageView.heightAnchor.constraint(equalToConstant: diameter), - ]) + /// Clips `view` to a circle and returns its size constraints, which `applyMetrics` sets. + private func configureCircle(_ view: UIView) -> [NSLayoutConstraint] { + view.contentMode = .scaleAspectFill + view.clipsToBounds = true + view.translatesAutoresizingMaskIntoConstraints = false + let size = [ + view.widthAnchor.constraint(equalToConstant: 0), + view.heightAnchor.constraint(equalToConstant: 0), + ] + NSLayoutConstraint.activate(size) + return size + } + + /// Sets the card's type, paddings and circle sizes from `ChatStyle`. + private func applyMetrics() { + let labelSize = ChatStyle.cardLabelSize + tokenLabel.font = .default(size: labelSize, weight: .semibold) + centerTokenLabel.font = .default(size: labelSize, weight: .semibold) + captionLabel.font = .default(size: labelSize, weight: .regular) + eachLabel.font = .default(size: labelSize, weight: .regular) + recipientsLabel.font = .default(size: labelSize, weight: .regular) + amountLabel.font = .default(size: ChatStyle.cardAmountSize, weight: .semibold) + let badge = ChatStyle.cardBadgeSize + badgeSize.forEach { $0.constant = badge } + for circle in [coinIcon, centerCoinIcon] { + circle.layer.cornerRadius = badge / 2 + } + headerTop.constant = ChatStyle.cardPaddingTop + headerLeading.constant = ChatStyle.cardPaddingX + bodyTop.constant = ChatStyle.cardHeaderGap + bodyBottom.constant = -ChatStyle.cardPaddingBottom + // A group card keeps a cash card's insets, with its recipients line where the body ends. + groupBodyTop.constant = ChatStyle.cardPaddingTop + recipientsTop.constant = ChatStyle.cardHeaderGap + recipientsBottom.constant = -ChatStyle.cardPaddingBottom } public override func prepareForReuse() { @@ -189,7 +213,7 @@ public final class ChatCashCardCell: ChatColumnCell { } /// - Parameter maxWidth: the card's width, in points. The owner derives it from the collection - /// view's width: 0.64 of the row between the column insets, matching Android's cash card. + /// view's width: the card's own width, narrowed only when the row can't hold it. public func configure(with message: ChatMessage, maxWidth: CGFloat, authorImageData: Data? = nil) { guard case .cash(let cash) = message.content else { return } cardWidthConstraint.constant = maxWidth @@ -201,6 +225,7 @@ public final class ChatCashCardCell: ChatColumnCell { eachLabel.isHidden = cash.group?.each == nil amountLabel.accessibilityValue = message.sentAt?.formattedChatMessageTime() + // A token whose branding never resolved shows no icon rather than a misleading one. coinIcon.isHidden = cash.iconURL == nil coinIcon.kf.setImage(with: cash.iconURL) let isGroup = cash.group != nil @@ -216,13 +241,12 @@ public final class ChatCashCardCell: ChatColumnCell { centerStack.setCustomSpacing(isGroup ? 12 : 4, after: captionLabel) centerStack.setCustomSpacing(8, after: eachLabel) + applyMetrics() + let radius = ChatStyle.cardRadius + // A money card keeps every corner round inside a run: it is its own object, not a bubble. card.apply( - fill: BubbleBackgroundView.fill(isFromSelf: message.sender == .me), - radii: BubbleBackgroundView.radii( - isFromSelf: message.sender == .me, - groupedAbove: message.joinsBubbleAbove, - groupedBelow: message.joinsBubbleBelow - ), + fill: ChatStyle.cardFill, + radii: RectangleCornerRadii(topLeading: radius, bottomLeading: radius, bottomTrailing: radius, topTrailing: radius), identity: message.id ) reactionRow.layoutWidth = maxWidth @@ -290,7 +314,7 @@ private final class ChatCashCardCellPreviewController: UICollectionViewControlle override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ChatCashCardCell.reuseIdentifier, for: indexPath) as! ChatCashCardCell - cell.configure(with: messages[indexPath.item], maxWidth: (collectionView.bounds.width - 24) * 0.64) + cell.configure(with: messages[indexPath.item], maxWidth: 240) return cell } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift index 4eb54808d..c14b2a90e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift @@ -80,10 +80,8 @@ public class ChatColumnCell: UICollectionViewCell { self.content = content column.axis = .vertical column.spacing = 4 - // Figma puts the name 12pt Medium at 50% white, 4pt above the bubble — the column's own - // spacing (nodes 10125:19169-19184). - authorName.font = .default(size: 12, weight: .medium) - authorName.textColor = UIColor(Color.textMain.opacity(Self.authorNameOpacity)) + // The name sits 4pt above the bubble — the column's own spacing — and is set in + // `configure` from `ChatStyle`, indented to line up with the bubble's text. authorName.isHidden = true column.addArrangedSubview(authorName) column.addArrangedSubview(content) @@ -194,7 +192,7 @@ public class ChatColumnCell: UICollectionViewCell { } /// The transcript's side margin, and the avatar gutter's own leading inset. - static let rowInset: CGFloat = 12 + static var rowInset: CGFloat { ChatStyle.rowInset } /// Between "Edited" and the receipt — the same gap the receipt sets between its own two halves. private static let metadataSpacing: CGFloat = 4 @@ -205,6 +203,19 @@ public class ChatColumnCell: UICollectionViewCell { private static let authorNameOpacity: Double = 0.5 private static let revealedTimeOpacity: CGFloat = 0.6 + /// A group author's name, indented by the bubble's text inset so it lines up with the text + /// below it rather than with the bubble's edge. + private static func authorNameText(_ name: String) -> NSAttributedString { + let style = NSMutableParagraphStyle() + style.firstLineHeadIndent = ChatStyle.bubblePaddingX + style.headIndent = ChatStyle.bubblePaddingX + return NSAttributedString(string: name, attributes: [ + .font: UIFont.default(size: ChatStyle.authorNameSize, weight: ChatStyle.authorNameWeight), + .foregroundColor: UIColor(Color.textMain.opacity(Self.authorNameOpacity)), + .paragraphStyle: style, + ]) + } + /// What an attributed incoming row gives up to the gutter: the circle plus the 8pt Figma puts /// between it and the bubble. Read by the layout to keep the bubble's max width proportional to /// the space the row actually has. @@ -364,7 +375,7 @@ public class ChatColumnCell: UICollectionViewCell { // The name opens a run. A roster that doesn't name this sender draws no label rather than an // empty line, which would read as stray padding above the bubble. - authorName.text = author.name + authorName.attributedText = Self.authorNameText(author.name) authorName.isHidden = message.isContinuationFromPrevious || author.name.isEmpty // The face opens the run with the name, so a run reads as one speaker with one avatar rather diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatDateSeparatorCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatDateSeparatorCell.swift index 49b8d5223..7c299ebdb 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatDateSeparatorCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatDateSeparatorCell.swift @@ -35,7 +35,22 @@ public final class ChatDateSeparatorCell: UICollectionViewCell { public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } public func configure(text: String) { - label.text = text + label.attributedText = Self.styled(text) + } + + /// The day ("Oct 20", "Today") and the time ("15:40") set apart, each in its own type. + private static func styled(_ text: String) -> NSAttributedString { + let dayFont = UIFont.default(size: 12, weight: .medium) + let timeFont = UIFont.default(size: 12, weight: .regular) + let color = UIColor.white.withAlphaComponent(0.5) + guard let match = text.range(of: #"\d{1,2}[:.]\d{2}.*$"#, options: .regularExpression), match.lowerBound > text.startIndex else { + return NSAttributedString(string: text, attributes: [.font: timeFont, .foregroundColor: color]) + } + // The day keeps its trailing space, so VoiceOver reads "Oct 20 15:40", not "Oct 2015:40". + let day = String(text[.. NSAttributedString { let font = UIFont.default(size: 12, weight: .bold) let color = UIColor(Color.textSecondary) - let symbol = UIImage.SymbolConfiguration(pointSize: 10, weight: .semibold) - func glyph(_ name: String) -> NSAttributedString { + let glyphSize: CGFloat = 12 + func glyph(_ icon: Icon) -> NSAttributedString { let attachment = NSTextAttachment() - attachment.image = UIImage(systemName: name, withConfiguration: symbol)? + attachment.image = UIImage.icon(icon) .withTintColor(color, renderingMode: .alwaysOriginal) + // Centred on the cap height, since an asset image has no baseline of its own. + attachment.bounds = CGRect(x: 0, y: (font.capHeight - glyphSize) / 2, width: glyphSize, height: glyphSize) return NSAttributedString(attachment: attachment) } - let result = NSMutableAttributedString(attributedString: glyph("lock.fill")) + let result = NSMutableAttributedString(attributedString: glyph(.lock)) result.append(NSAttributedString(string: " Encrypted ", attributes: [.font: font, .foregroundColor: color])) - result.append(glyph("chevron.right")) + result.append(glyph(.chevronRightMedium)) return result } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift index 3d0df7cef..dbe8b79ae 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift @@ -185,11 +185,11 @@ public final class ChatMediaViewerController: UIViewController, UIScrollViewDele singleTap.require(toFail: doubleTap) scrollView.addGestureRecognizer(singleTap) - configure(closeButton, symbol: "xmark", label: "Close", action: #selector(closeTapped)) - configure(reactButton, symbol: "face.smiling", label: "React", action: #selector(reactTapped)) - configure(replyButton, symbol: "arrowshape.turn.up.left", label: "Reply", action: #selector(replyTapped)) - configure(saveButton, symbol: "square.and.arrow.down", label: "Save", action: #selector(saveTapped)) - configure(forwardButton, symbol: "arrowshape.turn.up.right", label: "Forward", action: #selector(forwardTapped)) + configure(closeButton, icon: .crossMedium, label: "Close", action: #selector(closeTapped)) + configure(reactButton, icon: .emojiAddReaction, label: "React", action: #selector(reactTapped)) + configure(replyButton, icon: .arrowShareLeft, label: "Reply", action: #selector(replyTapped)) + configure(saveButton, icon: Self.saveIcon, label: "Save", action: #selector(saveTapped)) + configure(forwardButton, icon: .arrowShareRight, label: "Forward", action: #selector(forwardTapped)) if #available(iOS 26, *) { var glass = UIButton.Configuration.glass() @@ -204,7 +204,6 @@ public final class ChatMediaViewerController: UIViewController, UIScrollViewDele closeButton.translatesAutoresizingMaskIntoConstraints = false view.addSubview(closeButton) - // A system symbol, not the strip's "+" asset: that SVG carries a 0.5 group opacity and read dimmer than its neighbours. reactButton.isHidden = !request.canReact replyButton.isHidden = !request.canReply forwardButton.isHidden = !request.canForward @@ -257,8 +256,18 @@ public final class ChatMediaViewerController: UIViewController, UIScrollViewDele private static let chromeFill = UIColor(white: 0.12, alpha: 0.72) - private func configure(_ button: UIButton, symbol: String, label: String, action: Selector) { - button.configuration?.image = UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)) + private static let saveIcon: Icon = .arrowWallDown + + /// Draws `icon` as a 22pt template glyph, since a button configuration draws an asset at its own size. + private static func chromeIcon(_ icon: Icon) -> UIImage { + let size = CGSize(width: 22, height: 22) + return UIGraphicsImageRenderer(size: size) + .image { _ in UIImage.icon(icon).draw(in: CGRect(origin: .zero, size: size)) } + .withRenderingMode(.alwaysTemplate) + } + + private func configure(_ button: UIButton, icon: Icon, label: String, action: Selector) { + button.configuration?.image = Self.chromeIcon(icon) button.configuration?.baseForegroundColor = .white button.configuration?.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12) button.configurationUpdateHandler = { button in @@ -531,7 +540,7 @@ public final class ChatMediaViewerController: UIViewController, UIScrollViewDele }.withRenderingMode(.alwaysOriginal) } - static let reactSymbol = UIImage(systemName: "face.smiling", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)) + static let reactSymbol = chromeIcon(.emojiAddReaction) func dismissReactionStrip(animated: Bool) { guard let strip = reactionStrip else { return } @@ -573,12 +582,11 @@ public final class ChatMediaViewerController: UIViewController, UIScrollViewDele /// Swaps the save glyph for a checkmark for a moment, the way the Access Key's save button shows success. private func showSaved() { - let config = UIImage.SymbolConfiguration(pointSize: 20, weight: .regular) - saveButton.configuration?.image = UIImage(systemName: "checkmark", withConfiguration: config) + saveButton.configuration?.image = Self.chromeIcon(.checkmark1) saveButton.accessibilityLabel = "Saved" Task { [weak self] in try? await Task.sleep(for: .seconds(1.5)) - self?.saveButton.configuration?.image = UIImage(systemName: "square.and.arrow.down", withConfiguration: config) + self?.saveButton.configuration?.image = Self.chromeIcon(Self.saveIcon) self?.saveButton.accessibilityLabel = "Save" } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift index b60914799..a8281501a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift @@ -232,9 +232,10 @@ public nonisolated enum ChatMotion { /// /// Bound by the geometry, not by taste: the row below glides up into the line's place on /// `reflow` and draws over it, so the line must be gone before that row's top edge arrives. In a - /// bubble run that edge sits one tight row gap under the line and gets there about 40 % into the - /// glide, 0.08 s on the 0.35 s reflow; a longer fade is cut off by the arriving bubble. - public static let receiptExitFade: TimeInterval = 0.08 + /// bubble run that edge sits one `ChatStyle.runGap` under the line and gets there about 0.05 s + /// into the 0.35 s reflow; a longer fade is cut off by the arriving bubble. A tighter run gap + /// means a shorter fade. + public static let receiptExitFade: TimeInterval = 0.065 /// How long a message growing out of the typing bubble keeps its text hidden, so the words /// arrive into a bubble that has mostly taken its shape rather than spilling out of the dots'. diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatPaymentRequestCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPaymentRequestCell.swift index 20f5b6b67..4ac4e073c 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatPaymentRequestCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPaymentRequestCell.swift @@ -10,8 +10,8 @@ import UIKit import SwiftUI import FlipcashCore -/// A recycled cell for a group payment request widget: the request card in bubble chrome. A tap on -/// the row opens the request's details. +/// A recycled cell for a group payment request widget: the request card on the neutral money card +/// (shared `BubbleBackgroundView` chrome). A tap on the row opens the request's details. public final class ChatPaymentRequestCell: ChatColumnCell { public static let reuseIdentifier = "ChatPaymentRequestCell" @@ -89,13 +89,11 @@ public final class ChatPaymentRequestCell: ChatColumnCell { cardWidthConstraint.constant = width reactionRowWidthConstraint.constant = width + let radius = ChatStyle.cardRadius + // A money card keeps every corner round inside a run: it is its own object, not a bubble. card.apply( - fill: BubbleBackgroundView.fill(isFromSelf: message.sender == .me), - radii: BubbleBackgroundView.radii( - isFromSelf: message.sender == .me, - groupedAbove: message.joinsBubbleAbove, - groupedBelow: message.joinsBubbleBelow - ), + fill: ChatStyle.cardFill, + radii: RectangleCornerRadii(topLeading: radius, bottomLeading: radius, bottomTrailing: radius, topTrailing: radius), identity: message.id ) reactionRow.layoutWidth = width diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift index 5f1a35c2f..45d3b30f3 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift @@ -78,7 +78,6 @@ final class ChatQuotePanelView: UIView { private static let thumbnailSide: CGFloat = 34 /// The preview grey a quoted sentence is drawn in. - private static let snippetColor = UIColor.white.withAlphaComponent(0.55) override init(frame: CGRect) { super.init(frame: frame) @@ -109,7 +108,6 @@ final class ChatQuotePanelView: UIView { addSubview(authorLabel) snippetLabel.font = .default(size: 12, weight: .medium) - snippetLabel.textColor = Self.snippetColor // Two, matching the composer's strip: one line truncated most quoted sentences mid-clause, // which left the reply pointing at something the reader still had to go and open. snippetLabel.numberOfLines = 2 @@ -208,8 +206,12 @@ final class ChatQuotePanelView: UIView { } /// - Parameter thumbnail: where a quoted photo's thumbnail loads from, or nil until it resolves. - func configure(with quote: ChatQuote, thumbnail: ChatMediaLocation? = nil) { + /// - Parameter onSent: whether the panel sits in a sent bubble, whose text colour it takes. + func configure(with quote: ChatQuote, thumbnail: ChatMediaLocation? = nil, onSent: Bool = false) { targetStableID = quote.stableID + let ink = ChatStyle.text(isFromSelf: onSent) + let snippetColor = ink.withAlphaComponent(0.55) + tokenLabel.textColor = ink.withAlphaComponent(0.35) // The author's own colour, derived from their user id — the same colour the composer's strip // draws them in, and the same one Android does. An original with no known author falls back // to the neutral secondary, which is what `ComplementaryPalette` returns for a nil id. @@ -228,15 +230,15 @@ final class ChatQuotePanelView: UIView { tokenLabel.isHidden = false // A payment's amount is the whole of what was said, so it is read rather than glanced // at — a step brighter than the preview grey a quoted sentence gets. - snippetLabel.textColor = UIColor.white.withAlphaComponent(0.75) + snippetLabel.textColor = ink.withAlphaComponent(0.75) showThumbnail(blobID: nil, location: nil) case .media(let thumbnailBlobID, _): tokenLabel.isHidden = true - snippetLabel.textColor = Self.snippetColor + snippetLabel.textColor = snippetColor showThumbnail(blobID: thumbnailBlobID, location: thumbnail) case .text, .unavailable: tokenLabel.isHidden = true - snippetLabel.textColor = Self.snippetColor + snippetLabel.textColor = snippetColor showThumbnail(blobID: nil, location: nil) } let spoken = switch quote.kind { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift index 3a9773807..3c8f03f6a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift @@ -252,7 +252,8 @@ struct ShareProfileWidgetView: View { Button(action: onShare) { HStack(spacing: Layout.iconGap) { - Image(systemName: "square.and.arrow.up") + Image.icon(.shareOs) + .iconSize(20) Text(Copy.share) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatStyle.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatStyle.swift new file mode 100644 index 000000000..1f7561c06 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatStyle.swift @@ -0,0 +1,186 @@ +// +// ChatStyle.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import UIKit +import SwiftUI + +/// The chat transcript's look: bubble shape and type, sender colours, row spacing, the money card +/// and reaction pills. Values come from the Chat v2 design (Figma `v2/Platform` iOS, `v2/Color` +/// Dark); every transcript surface reads them from here so one change lands everywhere at once. +enum ChatStyle { + + // MARK: - Bubble + + /// A bubble's corner radius, capped at the capsule radius (half the bubble's height), so a + /// one-line bubble is a pill and a taller one keeps this radius. + static let bubbleRadius: CGFloat = 20 + /// The inner corner where two bubbles of one sender's run meet. + static let joinedRadius: CGFloat = 20 + /// Text inset from a bubble's leading and trailing edges. + static let bubblePaddingX: CGFloat = 14 + /// Text inset from a bubble's top and bottom edges. + static let bubblePaddingY: CGFloat = 10 + /// How a bubble's corners curve. + static let cornerStyle: RoundedCornerStyle = .continuous + /// Message body point size. + static let bodySize: CGFloat = 17 + /// Message body weight. + static let bodyWeight: UIFont.Weight = .regular + /// Message body line height. + static let bodyLineHeight: CGFloat = 22 + /// Whether bubbles draw the faint hairline outline the v1 chat had. + static let drawsHairline = false + /// The widest a bubble may grow, as a share of the transcript's width. + static let maxWidthFraction: CGFloat = 0.78 + + /// The message body font. + static let bodyFont: UIFont = .default(size: bodySize, weight: bodyWeight) + + /// Paragraph attributes that hold `font` to the body's line height, with the extra leading + /// split above and below the glyphs rather than all of it landing on top. + static func bodyLineAttributes(for font: UIFont) -> [NSAttributedString.Key: Any] { + let lineHeight = max(bodyLineHeight, font.lineHeight) + let style = NSMutableParagraphStyle() + style.minimumLineHeight = lineHeight + style.maximumLineHeight = lineHeight + return [.paragraphStyle: style, .baselineOffset: (lineHeight - font.lineHeight) / 4] + } + + // MARK: - Colour + + /// The transcript's accent: the app's focus blue. + static let accent = Color.badgeFocus + /// The sent bubble's fill: the accent, faint over the conversation background. + static let sentFill = UIColor(accent.opacity(0.32)) + /// Text on a sent bubble: the accent at full saturation lifted most of the way to white, in + /// Display P3 so it glows against the faint fill. + static let sentText = UIColor(displayP3Red: 0.88, green: 0.94, blue: 1, alpha: 1) + /// The received bubble's fill. + static let receivedFill = UIColor(Color(r: 35, g: 35, b: 37)) + /// Text on a received bubble. + static let receivedText = UIColor.white + /// Secondary text in the transcript: captions, the money card's labels, a resting reaction count. + static let secondaryText = UIColor(Color(r: 235, g: 235, b: 245).opacity(0.6)) + /// The tint under a reaction the viewer added. + static let accentTint = UIColor(accent.opacity(0.16)) + /// The ring around a reaction the viewer added. + static let accentRing = UIColor(accent.opacity(0.5)) + /// The accent itself, for text and glyphs. + static let accentColor = UIColor(accent) + + /// A bubble's fill for its sender. + static func fill(isFromSelf: Bool) -> UIColor { + isFromSelf ? sentFill : receivedFill + } + + /// Body text colour on a sender's bubble. + static func text(isFromSelf: Bool) -> UIColor { + isFromSelf ? sentText : receivedText + } + + /// Muted text on a sender's bubble: a placeholder, a quote's snippet, the "Edited" marker. + static func mutedText(isFromSelf: Bool) -> UIColor { + text(isFromSelf: isFromSelf).withAlphaComponent(0.6) + } + + // MARK: - Group names + + /// A group author's name above their first bubble. + static let authorNameSize: CGFloat = 12 + /// A group author's name weight. + static let authorNameWeight: UIFont.Weight = .regular + + // MARK: - Avatars + + /// A hairline ring around avatars, so a dark photo still reads as a circle. + static let avatarStrokeWidth: CGFloat = 1 + /// How much white the avatar ring takes. + static let avatarStrokeOpacity: CGFloat = 0.05 + /// The avatar beside a group author's first bubble. + static let authorAvatarSize: CGFloat = 28 + + // MARK: - Swipe to reply + + /// The reply arrow that follows a swiped row. + static let replyIconSize: CGFloat = 24 + /// The reply arrow's colour. + static let replyIconColor = Color.white.opacity(0.4) + + // MARK: - Mentions + + /// An `@handle`'s point size, a step under the body's. + static let mentionSize: CGFloat = 12.5 + /// An `@handle`'s weight. + static let mentionWeight: UIFont.Weight = .semibold + /// How far an `@handle` rises past centring its capitals on the body's. + static let mentionBaselineNudge: CGFloat = 0.5 + /// An `@handle`'s text colour, the same on either sender's bubble. + static let mentionText = UIColor(Color.white.opacity(0.77)) + + // MARK: - Spacing + + /// Between two bubbles of one sender's run. + static let runGap: CGFloat = 4 + /// Between two messages of one sender that the grouping window broke apart, and around a date. + static let momentGap: CGFloat = 10 + /// Between two speakers. + static let speakerGap: CGFloat = 16 + /// The transcript's side margin. + static let rowInset: CGFloat = 16 + + // MARK: - Money card + + /// The money card's fill: neutral, so money reads the same under any accent. + static let cardFill = UIColor(Color(r: 28, g: 28, b: 30)) + /// The money card's corner radius. + static let cardRadius: CGFloat = 22 + /// The money card's width. + static let cardWidth: CGFloat = 240 + /// The money card's inset from its leading and trailing edges. + static let cardPaddingX: CGFloat = 16 + /// The money card's inset above its header. + static let cardPaddingTop: CGFloat = 14 + /// The money card's inset below its amount. + static let cardPaddingBottom: CGFloat = 22 + /// Between the card's header and its caption. + static let cardHeaderGap: CGFloat = 14 + /// The token badge in the header. + static let cardBadgeSize: CGFloat = 20 + /// The card's label point size. + static let cardLabelSize: CGFloat = 13 + /// The card's amount point size. + static let cardAmountSize: CGFloat = 34 + + // MARK: - Reactions + + /// A reaction pill's height. + static let pillHeight: CGFloat = 32 + /// Between a pill's leading edge and its emoji. + static let pillLeadingPadding: CGFloat = 10 + /// Between a pill's count and its trailing edge. + static let pillTrailingPadding: CGFloat = 2 + /// Between pills, and between the message and its pills. + static let pillGap: CGFloat = 4 + /// Between a pill's emoji and its count. + static let pillInnerGap: CGFloat = 4 + /// The most digits a pill's count reserves room for. + static let pillCountRoom = 2 + /// A pill's emoji point size. + static let pillEmojiSize: CGFloat = 13 + /// A pill's count point size. + static let pillCountSize: CGFloat = 13 + /// The width of the ring around a reaction the viewer added. + static let pillRingWidth: CGFloat = 1.75 + /// A reaction nobody here added. + static let pillFill = UIColor(Color.white.opacity(0.09)) + /// Whether the add-reaction button sits on a glass circle, as in v1. + static let addReactionGlass = false + /// The add-reaction glyph. + static let addReactionIconSize: CGFloat = 19.3 +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatSwipeToReply.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSwipeToReply.swift index e7ffe59b9..f412da28b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatSwipeToReply.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSwipeToReply.swift @@ -60,14 +60,15 @@ final class ChatSwipeToReply: NSObject { recognizer.maximumNumberOfTouches = 1 affordance.bounds = CGRect(x: 0, y: 0, width: Self.affordanceDiameter, height: Self.affordanceDiameter) - affordance.backgroundColor = UIColor.white.withAlphaComponent(0.12) + affordance.backgroundColor = .clear affordance.layer.cornerRadius = Self.affordanceDiameter / 2 affordance.alpha = 0 - affordanceIcon.image = UIImage(systemName: SystemSymbol.replyArrow.rawValue) - affordanceIcon.tintColor = UIColor.white.withAlphaComponent(0.75) + affordanceIcon.image = UIImage.icon(.arrowShareLeftFilled) + affordanceIcon.tintColor = UIColor(ChatStyle.replyIconColor) affordanceIcon.contentMode = .scaleAspectFit - affordanceIcon.frame = affordance.bounds.insetBy(dx: 8, dy: 8) + let iconInset = (Self.affordanceDiameter - ChatStyle.replyIconSize) / 2 + affordanceIcon.frame = affordance.bounds.insetBy(dx: iconInset, dy: iconInset) affordance.addSubview(affordanceIcon) } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift index 192eb96c9..dd96872bd 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift @@ -33,9 +33,9 @@ public final class ChatSystemMessageCell: UICollectionViewCell { public override init(frame: CGRect) { super.init(frame: frame) - let glyph = UIImageView(image: UIImage(systemName: "info.circle", withConfiguration: UIImage.SymbolConfiguration(pointSize: 14))) + let glyph = UIImageView(image: UIImage.icon(.circleInfo)) glyph.tintColor = Self.textColor - glyph.contentMode = .center + glyph.contentMode = .scaleAspectFit glyph.isAccessibilityElement = false glyph.setContentHuggingPriority(.required, for: .horizontal) glyph.setContentCompressionResistancePriority(.required, for: .horizontal) @@ -68,6 +68,7 @@ public final class ChatSystemMessageCell: UICollectionViewCell { glyph.leadingAnchor.constraint(equalTo: group.leadingAnchor), glyph.topAnchor.constraint(equalTo: group.topAnchor), glyph.heightAnchor.constraint(equalToConstant: Self.lineHeight), + glyph.widthAnchor.constraint(equalToConstant: 18), label.leadingAnchor.constraint(equalTo: glyph.trailingAnchor, constant: Self.glyphGap), label.trailingAnchor.constraint(equalTo: group.trailingAnchor), diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatTypingIndicatorCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatTypingIndicatorCell.swift index 916b1dec0..b1a7ab48a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatTypingIndicatorCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatTypingIndicatorCell.swift @@ -35,7 +35,7 @@ public final class ChatTypingIndicatorCell: UICollectionViewCell { private static let dotSize: CGFloat = 7 - // Android's `TypingIndicator`: the transcript's 24pt sender avatar, overlapping by 8, with a 4pt + // Android's `TypingIndicator`: the transcript's sender avatar, overlapping by 8, with a 4pt // gap to the bubble and a 2pt ring cut out of each avatar beneath another. private static let avatarSize = ChatAuthorAvatarView.size private static let avatarOverlap: CGFloat = 8 diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 36e10cdbe..90770cf8e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -146,16 +146,12 @@ public final class ChatViewController: UICollectionViewController { public var pendingMediaProgress: ((String) -> ChatPhotoSendProgress?)? /// The widest a bubble may grow, as a share of the collection view's width. - private static let maxBubbleWidthFraction: CGFloat = 0.78 + private static var maxBubbleWidthFraction: CGFloat { ChatStyle.maxWidthFraction } - /// A cash card's width as a fraction of the row between the column insets — narrower than a - /// bubble, and taken of the inset row rather than the full width. Matches Android's - /// `CASH_CARD_WIDTH_FRACTION` so the card reads the same size on both. - private static let cashCardWidthFraction: CGFloat = 0.64 - - /// The width of a cash card in a row `available` points wide. + /// The width of a cash card in a row `available` points wide: the card's own width, narrowed + /// only when the row between the column insets can't hold it. private func cashCardWidth(available: CGFloat) -> CGFloat { - (available - ChatColumnCell.rowInset * 2) * Self.cashCardWidthFraction + min(ChatStyle.cardWidth, available - ChatColumnCell.rowInset * 2) } /// The width a link message's card is held to, or nil when it takes the bubble's width. A message @@ -202,14 +198,14 @@ public final class ChatViewController: UICollectionViewController { private enum RowGap { /// Two messages from one sender inside the grouping window. Their facing corners are /// already flattened, so the run needs only enough air to keep the bubbles apart. - static let tight: CGFloat = 5 + static var tight: CGFloat { ChatStyle.runGap } /// Two messages from one sender that the grouping window has broken apart, and either side /// of a date separator. The corners are round again, and the gap says what they no longer /// do — that these are separate moments. - static let normal: CGFloat = 10 + static var normal: CGFloat { ChatStyle.momentGap } /// A change of speaker, which reads as a break in the column rather than another row in /// the same run. - static let wide: CGFloat = 15 + static var wide: CGFloat { ChatStyle.speakerGap } } private let chatLayout = CollectionViewChatLayout() @@ -1784,7 +1780,7 @@ extension ChatViewController { let children = message.actions.map { action in UIAction( title: action.title, - image: UIImage(systemName: action.menuSymbol.rawValue), + image: UIImage.icon(action.menuIcon), attributes: action.isDestructive ? .destructive : [] ) { _ in switch action { @@ -1983,16 +1979,16 @@ protocol BubbleCarrying { private extension MessageCapability { - /// The menu row's glyph. Lives here rather than on the action itself because `SystemSymbol` is - /// this module's symbol registry, and `MessageCapability` is a core model. - var menuSymbol: SystemSymbol { + /// The menu row's glyph. Lives here rather than on the action itself because `Icon` is this + /// module's icon registry, and `MessageCapability` is a core model. + var menuIcon: Icon { switch self { - case .copy: .docFill - case .reply: .replyArrow - case .edit: .pencil - case .delete: .trash - case .report: .exclamationBubble - case .forward: .forwardArrow + case .copy: .squareBehindSquare1 + case .reply: .arrowShareLeft + case .edit: .pencil2 + case .delete: .trashCan + case .report: .bubbleAlert + case .forward: .arrowShareRight } } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ComposerFade.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ComposerFade.swift index 22dc37a72..2da16278c 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ComposerFade.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ComposerFade.swift @@ -48,6 +48,11 @@ final class ComposerFadeView: UIView { super.init(frame: .zero) isUserInteractionEnabled = false addSubview(colorRamp) + applyColors() + } + + /// Paints the ramp in the conversation background's current colour. + func applyColors() { let base = UIColor(Color.backgroundMain) colorRamp.set( colors: ChatFade.stops.map { base.withAlphaComponent($0.opacity) }, diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/EditedMarker.swift b/FlipcashUI/Sources/FlipcashUI/Chat/EditedMarker.swift index a180bae71..68b57120b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/EditedMarker.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/EditedMarker.swift @@ -24,7 +24,7 @@ enum EditedMarker { /// Inset from the bubble's trailing edge — the body's own inset, so the marker lines up with /// the text above it. - static let trailingInset: CGFloat = 12 + static var trailingInset: CGFloat { ChatStyle.bubblePaddingX } /// The run appended to the body to hold the marker's place: the marker's own string in its own /// font, drawn in clear. Reserving with the real glyphs makes the gap exactly the width the diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/Emoji/EmojiPickerSheet.swift b/FlipcashUI/Sources/FlipcashUI/Chat/Emoji/EmojiPickerSheet.swift index e287efb46..30daa8e73 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/Emoji/EmojiPickerSheet.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/Emoji/EmojiPickerSheet.swift @@ -107,7 +107,8 @@ public struct EmojiPickerSheet: View { private var searchField: some View { HStack(spacing: 12) { HStack(spacing: 8) { - Image(systemName: "magnifyingglass") + Image.icon(.magnifyingGlass) + .iconSize(22) .foregroundStyle(Color.textSecondary) TextField("Search", text: $query) .foregroundStyle(Color.textMain) @@ -116,7 +117,8 @@ public struct EmojiPickerSheet: View { Button { query = "" } label: { - Image(systemName: "xmark.circle.fill") + Image.icon(.circleXFilled) + .iconSize(22) .foregroundStyle(Color.textSecondary) } .accessibilityLabel("Clear search") diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift index 12f998b93..27d280754 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift @@ -31,10 +31,14 @@ public final class JumpToBottomModel { public var isShown: Bool { isVisible && !isSuppressed } } -/// The jump-to-bottom button: a 44pt circle in the composer's glass, with a chevron and an unread badge. +/// The jump-to-bottom button: a 36pt circle in the composer's glass, with a chevron and an unread badge. public struct JumpToBottomButtonView: View { - public static let diameter: CGFloat = 44 + public static let diameter: CGFloat = 36 + /// The button's hit area, a 44pt circle around the smaller drawn one. + public static let hitDiameter: CGFloat = 44 + /// How far the hit area reaches past the drawn circle on each side. + public static let hitOutset: CGFloat = (hitDiameter - diameter) / 2 /// The area above the bar's top edge, from its trailing side, that the button and its badge /// occupy, with the gap the bar leaves under the button. Touches in it go to the button. public static let reach = CGSize(width: 88, height: 20 + diameter + 12) @@ -54,18 +58,19 @@ public struct JumpToBottomButtonView: View { public var body: some View { let badgeText = UnreadBadgeText.text(for: unreadCount) Button(action: action) { - Image(systemName: "chevron.down") - .font(.system(size: 16, weight: .semibold)) + Image.icon(.chevronDownMedium) + .iconSize(22) .foregroundStyle(.white) .frame(width: Self.diameter, height: Self.diameter) .composerGlass(in: Circle(), id: "jump", namespace: glassNamespace) .composerRim(in: Circle()) - .contentShape(Circle()) .overlay(alignment: .topTrailing) { if let badgeText { badge(badgeText) } } + .frame(width: Self.hitDiameter, height: Self.hitDiameter) + .contentShape(Circle()) } .buttonStyle(.plain) .accessibilityLabel("Jump to latest message") diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkMessageCardView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkMessageCardView.swift index 26f758b01..5447454e8 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkMessageCardView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkMessageCardView.swift @@ -308,8 +308,8 @@ struct LinkMessageCardContent: View { } .frame(maxWidth: .infinity, alignment: .leading) if opens { - Image(systemName: "chevron.right") - .font(.system(size: 13, weight: .semibold)) + Image.icon(.chevronRightMedium) + .iconSize(16) .foregroundStyle(Color.white.opacity(Layout.chevronOpacity)) .accessibilityHidden(true) } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift index 4242ab544..45e0e18a7 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift @@ -75,8 +75,8 @@ public final class LinkableBubbleView: UIView { private var quoteThumbnail: ChatMediaLocation? /// The body's inset from the bubble's edges, and the bubble's own vertical padding. - private static let bodyInset: CGFloat = 12 - private static let bodyPadding: CGFloat = 9 + private static var bodyInset: CGFloat { ChatStyle.bubblePaddingX } + private static var bodyPadding: CGFloat { ChatStyle.bubblePaddingY } /// Forwarded from the panel: the stable id of the message to jump to. var onQuoteTap: ((String) -> Void)? { @@ -148,7 +148,7 @@ public final class LinkableBubbleView: UIView { textView.dataDetectorTypes = [] textView.font = .default(size: 16, weight: .medium) textView.textColor = .white - textView.linkTextAttributes = Self.linkAttributes + textView.linkTextAttributes = Self.linkAttributes(isFromSelf: false) textView.delegate = self textView.translatesAutoresizingMaskIntoConstraints = false spanTap.delegate = self @@ -298,6 +298,7 @@ public final class LinkableBubbleView: UIView { /// Fills the bubble; `quoteThumbnail` is where a quoted photo's thumbnail loads from. public func configure(with message: ChatMessage, quoteThumbnail: ChatMediaLocation? = nil) { + textView.linkTextAttributes = Self.linkAttributes(isFromSelf: message.sender == .me) // Shares the plain bubble's text builder so a link message gets the same body styling, the // same tombstone copy, and the same "Edited" reservation, with the link spans laid over it // from the preview the mapper already detected. @@ -350,6 +351,7 @@ public final class LinkableBubbleView: UIView { // A card row's "Edited" marker sits on the column's metadata line instead — see // `ChatLinkMessageCell`. editedLabel.isHidden = bare || !ChatBubbleView.showsEditedMarker(for: message) + editedLabel.textColor = ChatStyle.mutedText(isFromSelf: message.sender == .me) cardView.webIsBare = bare if bare, let card = message.linkPreview?.card { @@ -385,7 +387,7 @@ public final class LinkableBubbleView: UIView { // and UIKit resolves that by breaking one at random. if let quote = message.quote { quotePanel.isHidden = false - quotePanel.configure(with: quote, thumbnail: quoteThumbnail) + quotePanel.configure(with: quote, thumbnail: quoteThumbnail, onSent: message.sender == .me) NSLayoutConstraint.deactivate(quoteCollapse + [textTopToBubble, cardTopToBubble]) quoteTrailing.isActive = true textTopToQuote.isActive = !bare @@ -459,11 +461,13 @@ public final class LinkableBubbleView: UIView { extension LinkableBubbleView { - /// How a link reads in a bubble. - static let linkAttributes: [NSAttributedString.Key: Any] = [ - .foregroundColor: UIColor.white, - .underlineStyle: NSUnderlineStyle.single.rawValue, - ] + /// How a link reads in a sender's bubble: the body's colour, underlined. + static func linkAttributes(isFromSelf: Bool) -> [NSAttributedString.Key: Any] { + [ + .foregroundColor: ChatStyle.text(isFromSelf: isFromSelf), + .underlineStyle: NSUnderlineStyle.single.rawValue, + ] + } /// The bubble's body with every detected link underlined and every mention tagged for its pill. /// @@ -487,6 +491,13 @@ extension LinkableBubbleView { for mention in mentions where mention.length > 0 { guard mention.location >= 0, mention.location + mention.length <= result.length else { continue } result.addAttribute(.textItemTag, value: mention.username.value, range: mention.range) + let mentionFont = UIFont.default(size: ChatStyle.mentionSize, weight: ChatStyle.mentionWeight) + result.addAttribute(.font, value: mentionFont, range: mention.range) + // Lifts a smaller handle so its capitals centre on the body's, instead of sitting on its baseline. + let centring = (ChatStyle.bodyFont.capHeight - mentionFont.capHeight) / 2 + ChatStyle.mentionBaselineNudge + let bodyOffset = (ChatStyle.bodyLineAttributes(for: ChatStyle.bodyFont)[.baselineOffset] as? CGFloat) ?? 0 + result.addAttribute(.baselineOffset, value: bodyOffset + centring, range: mention.range) + result.addAttribute(.foregroundColor, value: ChatStyle.mentionText, range: mention.range) // Widens the space on both sides, so the pill grows into room the text gave up rather // than over the neighbouring words. if mention.location > 0 { @@ -563,12 +574,11 @@ extension LinkableBubbleView: UITextViewDelegate { enum MentionPill { static let fill = UIColor.white.withAlphaComponent(0.14) /// Horizontal room between the handle's glyphs and the pill's edge. - static let padding: CGFloat = 6 + static let padding: CGFloat = 5.75 /// Space between the pill's edge and the neighbouring words. - static let gap: CGFloat = 3 + static let gap: CGFloat = 0 /// Kern either side of the handle, making room for the padding and the gap. static let spacing: CGFloat = padding + gap - static let cornerRadius: CGFloat = 6 /// The pill for one line of a mention, from that line's selection rect. static func rect(around line: CGRect) -> CGRect { @@ -612,12 +622,21 @@ final class LinkTextView: UITextView { private func drawPills() { let path = UIBezierPath() for rect in mentionPillRects { - path.append(UIBezierPath(roundedRect: rect, cornerRadius: MentionPill.cornerRadius)) + path.append(UIBezierPath(roundedRect: rect, cornerRadius: rect.height / 2)) } pills.path = path.cgPath pills.frame = bounds } + /// The pill sized to the handle's own type, as tall as its glyphs rather than the body's line, + /// so a smaller handle gets a smaller pill. + private func fittedPill(_ line: CGRect, range: NSRange) -> CGRect { + let wide = MentionPill.rect(around: line) + guard let start = position(from: beginningOfDocument, offset: range.location + min(1, range.length)) else { return wide } + let glyphs = caretRect(for: start) + return CGRect(x: wide.minX, y: glyphs.minY, width: wide.width, height: glyphs.height) + } + /// The pill behind each line of each mention, in the text view's coordinates. var mentionPillRects: [CGRect] { guard let text = attributedText, text.length > 0 else { return [] } @@ -630,11 +649,11 @@ final class LinkTextView: UITextView { // from its own letters, ending where its last letter does. One too long for a line keeps // its selection rects. guard lines.count == 1, let end = glyphEnd(ofLastCharacterIn: range, of: text) else { - result += lines.map(MentionPill.rect(around:)) + result += lines.map { self.fittedPill($0, range: range) } return } let width = Self.unkerned(text.attributedSubstring(from: range)).size().width - result.append(MentionPill.rect(around: CGRect(x: end - width, y: line.minY, width: width, height: line.height))) + result.append(fittedPill(CGRect(x: end - width, y: line.minY, width: width, height: line.height), range: range)) } return result } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift index 6f7074848..66a45354f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift @@ -38,11 +38,11 @@ final class MessageBackdrop { private var effectView: UIVisualEffectView? /// Holds the blur at ``ChatBackdrop/blurFraction``, and is stopped by hand in `dismiss` and - /// `release` — see ``ChatBackdrop/frost(_:)``. + /// `release` — see ``ChatBackdrop/frost(_:fraction:)``. private var blurStrength: UIViewPropertyAnimator? private var spotlight: UIView? - /// Replaces the menu's dimming once the menu is gone. Lives inside the blur, so it is clipped - /// and framed with it and sits under the floated copy. + /// Darkens the blur from its first frame, under the menu's own dimming and on through an edit. + /// Lives inside the blur, so it is clipped and framed with it and sits under the floated copy. private var dim: UIView? /// Holds the floated copy and clips it to the composer bar, so a copy of a row that has scrolled /// past either edge can't draw over the composer or the navigation bar. @@ -67,6 +67,15 @@ final class MessageBackdrop { // available to animate. blurStrength = ChatBackdrop.frost(blur) + // The dim is up from the first frame, under the menu too. + let earlyDim = UIView(frame: blur.bounds) + earlyDim.backgroundColor = .black + earlyDim.alpha = ChatBackdrop.dimAlpha + earlyDim.autoresizingMask = [.flexibleWidth, .flexibleHeight] + earlyDim.isUserInteractionEnabled = false + blur.contentView.addSubview(earlyDim) + dim = earlyDim + UIView.animate(withDuration: Self.fadeDuration) { blur.alpha = 1 } } @@ -89,11 +98,10 @@ final class MessageBackdrop { // The frame is driven by the layout pass from here on, so the host can no longer resize it. blur.autoresizingMask = [] - // Fade the stand-in dim in now, while the menu is still up: its own dimming fades out with - // the dismissal, so the two cross and the screen never brightens between the states. - let dim = UIView(frame: blur.bounds) + // The dim has been at full strength since `present`; it moves with the blur and stays as the + // menu's own dimming fades out with the dismissal. + let dim = self.dim ?? UIView(frame: blur.bounds) dim.backgroundColor = .black - dim.alpha = 0 dim.autoresizingMask = [.flexibleWidth, .flexibleHeight] dim.isUserInteractionEnabled = false blur.contentView.addSubview(dim) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageLinkBackChip.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageLinkBackChip.swift index 28c9ac25c..e836af47a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/MessageLinkBackChip.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageLinkBackChip.swift @@ -23,8 +23,8 @@ public struct MessageLinkBackChip: View { public var body: some View { Button(action: action) { HStack(spacing: 6) { - Image(systemName: "chevron.left") - .font(.system(size: 18, weight: .medium)) + Image.icon(.chevronLeftMedium) + .iconSize(22) Text(Self.label(title)) .font(.appTextSmall) .lineLimit(1) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ReactionPillRowView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ReactionPillRowView.swift index a624811a9..cef8ead28 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ReactionPillRowView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ReactionPillRowView.swift @@ -45,7 +45,7 @@ final class ReactionPillRowView: UIView { } private var pillViews: [ReactionPillView] = [] - private let addButton = ReactionAddButton(icon: .asset(.addReaction), accessibilityLabel: "Add reaction") + private let addButton = ReactionAddButton(icon: .icon(.emojiAddReaction), accessibilityLabel: "Add reaction") private let boostButton = ReactionAddButton(title: "$", accessibilityLabel: "Paid reaction") private let overflowButton = ReactionOverflowButton() @@ -420,7 +420,7 @@ final class ReactionPillRowView: UIView { } private static let maxLines = 2 - private static let buttonSize = CGSize(width: 28, height: 28) + private static var buttonSize: CGSize { CGSize(width: ChatStyle.pillHeight, height: ChatStyle.pillHeight) } override func sizeThatFits(_ size: CGSize) -> CGSize { guard !isEmpty else { return .zero } @@ -444,7 +444,7 @@ final class ReactionPillRowView: UIView { /// The gap between the bubble and the first line of pills — the column's own spacing, which the /// column leaves out above this row so an empty row adds nothing. - private static let topGap: CGFloat = 4 + private static var topGap: CGFloat { ChatStyle.pillGap } @objc private func addTapped() { resolveTap(.add) @@ -525,7 +525,7 @@ struct ReactionRowLayout: Equatable { var hiddenCount: Int var height: CGFloat - static let spacing: CGFloat = 6 + static var spacing: CGFloat { ChatStyle.pillGap } /// Lays the pills out within `maxLines` lines of `width`, or on as many lines as they need when /// `maxLines` is nil. When they don't all fit, as many pills as leave room for the "N more" pill (and @@ -601,9 +601,9 @@ struct ReactionRowLayout: Equatable { /// The "N more" pill standing in for the reactions past the second line; tapping it shows them all. private final class ReactionOverflowButton: UIControl { - private static let height: CGFloat = 28 - private static let horizontalPadding: CGFloat = 10 - private static let font = UIFont.default(size: 13, weight: .medium) + private static var height: CGFloat { ChatStyle.pillHeight } + private static var horizontalPadding: CGFloat { ChatStyle.pillLeadingPadding } + private static var font: UIFont { .default(size: ChatStyle.pillCountSize, weight: .semibold) } private let label = UILabel() @@ -624,11 +624,11 @@ private final class ReactionOverflowButton: UIControl { override init(frame: CGRect) { super.init(frame: frame) - backgroundColor = UIColor.white.withAlphaComponent(0.06) + backgroundColor = ChatStyle.pillFill layer.cornerRadius = Self.height / 2 clipsToBounds = true label.font = Self.font - label.textColor = UIColor(Color.textMain) + label.textColor = ChatStyle.secondaryText label.textAlignment = .center addSubview(label) isAccessibilityElement = true @@ -648,12 +648,13 @@ private final class ReactionOverflowButton: UIControl { } } -/// The row's round glass button at pill height: the trailing "+" that opens the picker, or the leading -/// "$" that starts a paid reaction. Built from a glass effect view rather than -/// `UIButton.Configuration.glass()`, whose platter lays itself out larger than a 28pt frame. +/// The row's round button at pill height: the trailing "+" that opens the picker, or the leading +/// "$" that starts a paid reaction. Bare unless `ChatStyle.addReactionGlass` puts it on a glass +/// circle. Built from a glass effect view rather than `UIButton.Configuration.glass()`, whose +/// platter lays itself out larger than a 28pt frame. private final class ReactionAddButton: UIControl { - private static let iconSize: CGFloat = 20 + private static var iconSize: CGFloat { ChatStyle.addReactionIconSize } private let surface: UIView private let icon: UIImageView? @@ -666,8 +667,8 @@ private final class ReactionAddButton: UIControl { convenience init(title: String, accessibilityLabel: String) { let label = UILabel() label.text = title - label.font = .appTextMedium - label.textColor = UIColor(Color.textMain) + label.font = .default(size: ChatStyle.pillCountSize, weight: .semibold) + label.textColor = ChatStyle.secondaryText label.textAlignment = .center self.init(icon: nil, label: label, accessibilityLabel: accessibilityLabel) } @@ -689,6 +690,7 @@ private final class ReactionAddButton: UIControl { addSubview(surface) if let icon { icon.contentMode = .scaleAspectFit + icon.tintColor = ChatStyle.secondaryText addSubview(icon) } if let label { addSubview(label) } @@ -706,6 +708,7 @@ private final class ReactionAddButton: UIControl { override func layoutSubviews() { super.layoutSubviews() + surface.isHidden = !ChatStyle.addReactionGlass surface.frame = bounds surface.layer.cornerRadius = bounds.height / 2 icon?.frame = CGRect( diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ReactionPillView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ReactionPillView.swift index 5ba1b1e57..3af2086ff 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ReactionPillView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ReactionPillView.swift @@ -10,12 +10,12 @@ import UIKit import SwiftUI import FlipcashCore -/// One reaction pill: emoji + count, capsule-shaped over the same wash a received bubble sits on. -/// A boosted pill adds its dollar total after the emoji and shows the count only above one. -/// A self-reacted pill draws an outline, which never changes its width. A boosted pill the viewer reacted to turns gold: -/// gold outline, amount and count over a gold wash. Someone else's boosted pill keeps the resting look with its amount at half -/// white. Tap toggles the reaction; the row it sits in owns the long press, so a press just off the pill still opens the -/// reactors sheet. +/// One reaction pill: emoji + count on a capsule. A boosted pill adds its dollar total after the +/// emoji and shows the count only above one. A self-reacted pill takes the accent's tint, ring and +/// count colour; the ring never changes its width. A boosted pill the viewer reacted to turns gold +/// instead: gold ring, amount and count over a gold wash. Someone else's boosted pill keeps the +/// resting look with its amount at half white. Tap toggles the reaction; the row it sits in owns +/// the long press, so a press just off the pill still opens the reactors sheet. final class ReactionPillView: UIView { private let stack = UIStackView() @@ -34,47 +34,42 @@ final class ReactionPillView: UIView { /// The emoji this pill last drew, so the row can keep the pill across a reconfigure. var emoji: String { pill?.emoji ?? "" } - private static let height: CGFloat = 28 - private static let horizontalPadding: CGFloat = 10 - private static let selfReactedFill = UIColor.white.withAlphaComponent(0.12) - private static let restingFill = UIColor.white.withAlphaComponent(0.06) - private static let selfReactedBorder = UIColor.white.withAlphaComponent(0.24) + private static var selfReactedFill: UIColor { ChatStyle.accentTint } + private static var restingFill: UIColor { ChatStyle.pillFill } + private static var selfReactedBorder: UIColor { ChatStyle.accentRing } + private static var selfReactedBorderWidth: CGFloat { ChatStyle.pillRingWidth } /// The border and amount of a boosted pill the viewer reacted to; its fill is this at 10% and its count at 50%. private static let boosted = UIColor(Color.reactionBoost) private static let boostedFill = boosted.withAlphaComponent(0.1) private static let boostedCount = boosted.withAlphaComponent(0.5) /// The amount on someone else's boosted pill. private static let othersBoostAmount = UIColor.white.withAlphaComponent(0.5) - private static let count = UIColor(Color.textMain) - /// Android's `thickBorder`. - private static let selfReactedBorderWidth: CGFloat = 2 + + private lazy var heightConstraint = heightAnchor.constraint(equalToConstant: ChatStyle.pillHeight) + private lazy var stackLeading = stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: ChatStyle.pillLeadingPadding) + private lazy var stackTrailing = stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -ChatStyle.pillTrailingPadding) override init(frame: CGRect) { super.init(frame: frame) - layer.cornerRadius = Self.height / 2 clipsToBounds = true stack.axis = .horizontal - stack.spacing = 4 + stack.spacing = ChatStyle.pillInnerGap stack.alignment = .center stack.isUserInteractionEnabled = false stack.translatesAutoresizingMaskIntoConstraints = false addSubview(stack) - emojiLabel.font = .systemFont(ofSize: 15) - countLabel.font = Self.countFont - countLabel.textColor = Self.count - countLabel.textAlignment = .center - boostLabel.font = Self.countFont + countLabel.textAlignment = .left boostLabel.isHidden = true stack.addArrangedSubview(emojiLabel) stack.addArrangedSubview(boostLabel) stack.addArrangedSubview(countLabel) NSLayoutConstraint.activate([ - heightAnchor.constraint(equalToConstant: Self.height), - stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.horizontalPadding), - stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.horizontalPadding), + heightConstraint, + stackLeading, + stackTrailing, stack.centerYAnchor.constraint(equalTo: centerYAnchor), countWidth, ]) @@ -96,20 +91,23 @@ final class ReactionPillView: UIView { func configure(with pill: ReactionPill, countDigits: Int = 2, canReact: Bool, animated: Bool = false) { let previous = self.pill self.pill = pill + applyMetrics() emojiLabel.text = pill.emoji if animated, let previous, previous.emoji == pill.emoji, previous.count != pill.count { rollCount(up: pill.count > previous.count) } countLabel.text = "\(pill.count)" - countWidth.constant = Self.countWidth(digits: max(countDigits, "\(pill.count)".count)) + countWidth.constant = Self.countWidth(digits: max(min(countDigits, ChatStyle.pillCountRoom), "\(pill.count)".count)) let boostText = pill.boost.map { ($0.displayed ?? $0.usd).formatted() } boostLabel.text = boostText Self.setHidden(boostLabel, boostText == nil) Self.setHidden(countLabel, boostText != nil && pill.count <= 1) + // The trailing padding leans on the count's reserved room, so with no count the amount takes the leading padding. + if countLabel.isHidden { stackTrailing.constant = -ChatStyle.pillLeadingPadding } let isGold = pill.boost != nil && pill.selfReacted boostLabel.textColor = isGold ? Self.boosted : Self.othersBoostAmount - countLabel.textColor = isGold ? Self.boostedCount : Self.count + countLabel.textColor = isGold ? Self.boostedCount : (pill.selfReacted ? ChatStyle.accentColor : ChatStyle.secondaryText) let fill = isGold ? Self.boostedFill : pill.selfReacted ? Self.selfReactedFill : Self.restingFill let borderWidth = pill.selfReacted ? Self.selfReactedBorderWidth : 0 let borderColor = (isGold ? Self.boosted : Self.selfReactedBorder).cgColor @@ -154,10 +152,26 @@ final class ReactionPillView: UIView { ceil(widestDigit * CGFloat(digits)) } - private static let countFont = UIFont.default(size: 13, weight: .medium) - private static let widestDigit: CGFloat = (0...9) - .map { ("\($0)" as NSString).size(withAttributes: [.font: countFont]).width } - .max() ?? 0 + private static var countFont: UIFont { .default(size: ChatStyle.pillCountSize, weight: .semibold) } + private static var widestDigit: CGFloat { + let font = countFont + return (0...9) + .map { ("\($0)" as NSString).size(withAttributes: [.font: font]).width } + .max() ?? 0 + } + + /// Sets the pill's size, paddings and type from `ChatStyle`. + private func applyMetrics() { + let height = ChatStyle.pillHeight + stack.spacing = ChatStyle.pillInnerGap + heightConstraint.constant = height + layer.cornerRadius = height / 2 + stackLeading.constant = ChatStyle.pillLeadingPadding + stackTrailing.constant = -ChatStyle.pillTrailingPadding + emojiLabel.font = .systemFont(ofSize: ChatStyle.pillEmojiSize) + countLabel.font = Self.countFont + boostLabel.font = Self.countFont + } /// Rolls the count to its next value: up for a rise, down for a fall, the way a counter turns. /// Reduce Motion gets a crossfade instead. diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ReactionStripView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ReactionStripView.swift index df0515b4b..5259eea6a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ReactionStripView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ReactionStripView.swift @@ -32,6 +32,8 @@ final class ReactionStripView: UIView, UIScrollViewDelegate { private static let itemSpacing: CGFloat = 4 private static let inset: CGFloat = 8 private static let addSize: CGFloat = 40 + /// The add glyph's tint, the 50% white the old asset carried in its own strokes. + private static let addTint = UIColor.white.withAlphaComponent(0.5) /// How far the leading fade reaches in from the edge, and how much scroll either fade takes to /// grow in, so neither snaps on. private static let edgeFade: CGFloat = 20 @@ -95,12 +97,14 @@ final class ReactionStripView: UIView, UIScrollViewDelegate { if #available(iOS 26, *) { var configuration = UIButton.Configuration.glass() - configuration.image = .asset(.addReaction) + configuration.image = .icon(.emojiAddReaction) + configuration.baseForegroundColor = Self.addTint configuration.cornerStyle = .capsule configuration.contentInsets = .zero addButton.configuration = configuration } else { - addButton.setImage(.asset(.addReaction), for: .normal) + addButton.setImage(.icon(.emojiAddReaction), for: .normal) + addButton.tintColor = Self.addTint addButton.backgroundColor = UIColor.white.withAlphaComponent(0.18) addButton.layer.cornerRadius = Self.addSize / 2 addButton.clipsToBounds = true diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/TranscriptBlur.swift b/FlipcashUI/Sources/FlipcashUI/Chat/TranscriptBlur.swift index bc9f6135f..6fd4d1e04 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/TranscriptBlur.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/TranscriptBlur.swift @@ -18,9 +18,9 @@ import UIKit /// transcript, which is what the design shows. /// /// The frosting itself is ``ChatBackdrop``'s, the same one a long press puts behind a lifted -/// message, including its dim: this blur stands alone with no context menu above it, so nothing -/// else is darkening the screen, and the material on its own lifts a near-black transcript's blacks -/// instead of hiding them. +/// message, at the gate's own strength (``ChatBackdrop/gateBlurFraction``) and including its dim: +/// this blur stands alone with no context menu above it, so nothing else is darkening the screen, +/// and the material on its own lifts a near-black transcript's blacks instead of hiding them. @MainActor final class TranscriptBlur { @@ -37,8 +37,8 @@ final class TranscriptBlur { /// Layered in the effect view's content view, so it fades with the blur and needs no separate /// animation of its own. private let dim = UIView() - /// Holds the material at ``ChatBackdrop/blurFraction``. Kept for the life of the screen, and - /// stopped below before it deallocates — see ``ChatBackdrop/frost(_:)``. + /// Holds the material at ``ChatBackdrop/gateBlurFraction``. Kept for the life of the screen, and + /// stopped below before it deallocates — see ``ChatBackdrop/frost(_:fraction:)``. private var strength: UIViewPropertyAnimator? /// The blur itself, so anything meant to be seen *through* it can be layered directly beneath. @@ -93,7 +93,7 @@ final class TranscriptBlur { dim.bottomAnchor.constraint(equalTo: effectView.contentView.bottomAnchor), ]) - strength = ChatBackdrop.frost(effectView) + strength = ChatBackdrop.frost(effectView, fraction: ChatBackdrop.gateBlurFraction) } isolated deinit { diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedColorExtractor.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedColorExtractor.swift new file mode 100644 index 000000000..df5e14b2c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedColorExtractor.swift @@ -0,0 +1,184 @@ +// +// BleedColorExtractor.swift +// FlipcashUI +// + +import CoreGraphics +import UIKit + +/// Extracts the "bleed" color of an image: the color of its bottom edge, so a gradient from the +/// image into that color reads as the image continuing. +/// +/// Samples the bottom strip into a tiny bitmap, weights pixels toward the bottom edge and the +/// horizontal center, then takes the dominant k-means cluster in OKLab. Pure and thread-safe; call +/// it off the main actor. +nonisolated enum BleedColorExtractor { + + /// The fraction of the image's height, from the bottom edge, that is sampled. + private static let sampleFraction = 0.26 + + /// The width of the downsampled copy the strip is cropped from; the sample is clustered into a + /// 48 px bitmap anyway, so decoding a full-size cover would only compete with the first frame. + private static let decodeWidth: CGFloat = 192 + + /// The bleed color of `image`, extracted off the main actor. + @concurrent + static func extract(image: UIImage) async -> BleedRGB? { + guard image.size.width > 0 else { return nil } + let scale = min(1, decodeWidth / (image.size.width * image.scale)) + let target = CGSize( + width: (image.size.width * image.scale * scale).rounded(), + height: (image.size.height * image.scale * scale).rounded() + ) + let downsampled = scale < 1 ? image.preparingThumbnail(of: target) : image + guard let cgImage = (downsampled ?? image).cgImage else { return nil } + return extract(from: cgImage) + } + + /// The bleed color of the bottom strip of `image`. + static func extract(from image: CGImage) -> BleedRGB? { + guard let samples = sampleBottomStrip(of: image, fraction: sampleFraction), + !samples.isEmpty else { return nil } + return dominantCluster(samples) ?? weightedAverage(samples) + } + + // MARK: - Sampling + + private struct Sample { + /// Linear-light sRGB. + var red: Double + var green: Double + var blue: Double + var weight: Double + } + + private static let sampleWidth = 48 + + private static func sampleBottomStrip(of image: CGImage, fraction: Double) -> [Sample]? { + let imageWidth = image.width + let imageHeight = image.height + guard imageWidth > 0, imageHeight > 0 else { return nil } + + let stripHeight = max(1, Int((Double(imageHeight) * fraction).rounded())) + let cropRect = CGRect(x: 0, y: imageHeight - stripHeight, width: imageWidth, height: stripHeight) + guard let strip = image.cropping(to: cropRect) else { return nil } + + let width = sampleWidth + let height = min(24, max(4, Int((Double(width) * Double(stripHeight) / Double(imageWidth)).rounded()))) + let bytesPerRow = width * 4 + var pixels = [UInt8](repeating: 0, count: bytesPerRow * height) + guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) else { return nil } + + let drawn = pixels.withUnsafeMutableBytes { buffer -> Bool in + guard let context = CGContext( + data: buffer.baseAddress, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) else { return false } + context.interpolationQuality = .medium + context.draw(strip, in: CGRect(x: 0, y: 0, width: width, height: height)) + return true + } + guard drawn else { return nil } + + var samples: [Sample] = [] + samples.reserveCapacity(width * height) + for row in 0.. 1 ? Double(row) / Double(height - 1) : 1 + let rowWeight = 0.3 + 0.7 * pow(vertical, 1.6) + for column in 0.. 1 ? Double(column) / Double(width - 1) : 0.5 + let columnWeight = 0.35 + 0.65 * (1 - abs(horizontal * 2 - 1)) + let offset = row * bytesPerRow + column * 4 + samples.append(Sample( + red: BleedRGB.linear(Double(pixels[offset]) / 255), + green: BleedRGB.linear(Double(pixels[offset + 1]) / 255), + blue: BleedRGB.linear(Double(pixels[offset + 2]) / 255), + weight: rowWeight * columnWeight + )) + } + } + return samples + } + + // MARK: - Average + + private static func weightedAverage(_ samples: [Sample]) -> BleedRGB { + var red = 0.0, green = 0.0, blue = 0.0, total = 0.0 + for sample in samples { + red += sample.red * sample.weight + green += sample.green * sample.weight + blue += sample.blue * sample.weight + total += sample.weight + } + guard total > 0 else { return BleedRGB(red: 0.5, green: 0.5, blue: 0.5) } + return BleedRGB( + red: BleedRGB.encoded(red / total), + green: BleedRGB.encoded(green / total), + blue: BleedRGB.encoded(blue / total) + ) + } + + // MARK: - K-means + + private static func dominantCluster(_ samples: [Sample], k: Int = 3, iterations: Int = 10) -> BleedRGB? { + let points = samples.map { OKLab(linearRed: $0.red, green: $0.green, blue: $0.blue) } + let weights = samples.map(\.weight) + guard points.count >= k else { return nil } + + // Deterministic seeds: lightness quantiles (dark, mid, light). + let byLightness = points.indices.sorted { points[$0].l < points[$1].l } + var centers = (0.. 0 { + let sum = sums[cluster] + centers[cluster] = OKLab(l: sum.l / sum.w, a: sum.a / sum.w, b: sum.b / sum.w) + } + } + + var clusterWeights = [Double](repeating: 0, count: k) + for (index, cluster) in assignment.enumerated() { + clusterWeights[cluster] += weights[index] + } + // Dominance, nudged toward colorful clusters and away from crushed shadows / blown highlights. + let scored = centers.indices.map { cluster -> (Int, Double) in + let center = centers[cluster] + var score = clusterWeights[cluster] * (1 + 2.5 * center.chroma) + if center.l < 0.18 || center.l > 0.95 { score *= 0.6 } + return (cluster, score) + } + guard let winner = scored.max(by: { $0.1 < $1.1 })?.0 else { return nil } + return centers[winner].gamutMappedRGB + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedColorStore.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedColorStore.swift new file mode 100644 index 000000000..0803cae7b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedColorStore.swift @@ -0,0 +1,73 @@ +// +// BleedColorStore.swift +// FlipcashUI +// + +import SwiftUI + +/// Main-actor cache of extracted bleed colors, keyed by a caller-supplied id, and of the palettes +/// built from them. Extraction runs off the main actor and is de-duplicated while in flight. +/// +/// Each id has its own observable box, so a view re-renders only when its own color resolves. +final class BleedColorStore { + + /// The shared store. + static let shared = BleedColorStore() + + private struct PaletteKey: Hashable { + let base: BleedRGB + let settings: BleedSettings + } + + private var colors: [String: ObservableBox] = [:] + private var inFlight: [String: Task] = [:] + /// Palettes are pure functions of (color, settings); building one runs OKLab conversions, a + /// gamut search and contrast loops, so each is built once. + private var palettes: [PaletteKey: BleedPalette] = [:] + + /// The cached color for `id`, or `nil` before it resolves. + func color(for id: String) -> BleedRGB? { + box(for: id).value + } + + /// The palette for an extracted color, memoized. + func palette(for base: BleedRGB, settings: BleedSettings) -> BleedPalette { + let key = PaletteKey(base: base, settings: settings) + if let palette = palettes[key] { return palette } + let palette = BleedPalette(base: base, settings: settings) + palettes[key] = palette + return palette + } + + /// Computes the color of `image` once under `id` and caches it; safe to call from `.task(id:)` + /// on every appearance. + @discardableResult + func resolve(_ id: String, image: UIImage) async -> BleedRGB? { + let box = box(for: id) + if let cached = box.value { return cached } + + let task: Task + if let existing = inFlight[id] { + task = existing + } else { + task = Task { await BleedColorExtractor.extract(image: image) } + inFlight[id] = task + } + let result = await task.value + inFlight[id] = nil + + if let result, box.value == nil { + withAnimation(.easeOut(duration: 0.25)) { + box.value = result + } + } + return result + } + + private func box(for id: String) -> ObservableBox { + if let box = colors[id] { return box } + let box = ObservableBox() + colors[id] = box + return box + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedFade.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedFade.swift new file mode 100644 index 000000000..3097c756e --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedFade.swift @@ -0,0 +1,46 @@ +// +// BleedFade.swift +// FlipcashUI +// + +import SwiftUI + +/// The image-to-color fade at the bottom of a bleed image: `color` eases in over `height` on the +/// smootherstep curve, then keeps going, solid, `seamOverlap` past the image's bottom and side edges. +/// +/// The overhang keeps the seam invisible: when the image and its fade end on the same edge, that +/// row is antialiased twice whenever it lands between pixels, and a hairline of the image shows +/// through. Overlay it on the clipped image, aligned to the bottom, over a page of the same color. +public struct BleedFade: View { + + let color: Color + /// The length of the eased ramp, ending at the image's bottom edge. + let height: CGFloat + + /// How far the solid color runs past the image's bottom and side edges. + static let seamOverlap: CGFloat = 2 + + /// A fade of `color` over `height`. + public init(color: Color, height: CGFloat) { + self.color = color + self.height = height + } + + public var body: some View { + let overlap = Self.seamOverlap + let total = max(height, 0) + overlap + + // One gradient for the ramp and the overhang: a solid strip butted against the ramp's end + // would be the same coincident edge again, one layer up. + LinearGradient( + stops: EasedStops.fade(to: color, end: max(height, 0) / total), + startPoint: .top, + endPoint: .bottom + ) + .frame(height: total) + .padding(.horizontal, -overlap) + .padding(.bottom, -overlap) + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedPalette.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedPalette.swift new file mode 100644 index 000000000..9b9406485 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedPalette.swift @@ -0,0 +1,278 @@ +// +// BleedPalette.swift +// FlipcashUI +// + +import SwiftUI + +/// Colors for content that sits on an image's bleed color: the deep background plus text and +/// control colors chosen for contrast on it. Read it with `@Environment(\.bleedPalette)` inside a +/// `.bleedPalette(key:image:settings:)` subtree. +public struct BleedPalette: Hashable { + + /// The bleed fill: the image's bottom-edge color, pulled deep enough to carry white text. + public var background: Color + /// Primary text on `background`, white. + public var primaryText: Color + /// Secondary text as a flat color, for runs that can't take a blend mode; prefer `secondaryStyle`. + public var secondaryText: Color + /// Paragraph text as a flat color; prefer `bodyStyle`. + public var bodyText: Color + var secondaryTextStyle: BleedTextStyle + var tertiaryTextStyle: BleedTextStyle + var bodyTextStyle: BleedTextStyle + /// Hairlines and dividers on `background`. + public var hairline: Color + var controlSecondaryTextStyle: BleedTextStyle + /// A lifted tint of `background` for surfaces and controls over it. + public var controlTint: Color + /// The label color for controls filled with `controlTint`. + public var controlLabel: Color + /// The scheme content on the bleed renders in; always dark. + public var colorScheme: ColorScheme + /// The resolved background as sRGB; `nil` for the placeholder. + var backgroundRGB: BleedRGB? + + /// Whether the palette was derived from an image rather than being the placeholder. + public var isResolved: Bool { backgroundRGB != nil } + + /// The palette while an image's color is still being computed, or when there is no image: the + /// app's own dark page and text colors. + public static var placeholder: BleedPalette { + BleedPalette( + background: .backgroundMain, + primaryText: .textMain, + secondaryText: .textSecondary, + bodyText: .textMain, + secondaryTextStyle: BleedTextStyle(color: .textSecondary), + tertiaryTextStyle: BleedTextStyle(color: .textTertiary), + bodyTextStyle: BleedTextStyle(color: .textMain), + controlSecondaryTextStyle: BleedTextStyle(color: .textSecondary), + hairline: .rowSeparator, + controlTint: .backgroundRow, + controlLabel: .textMain, + colorScheme: .dark, + backgroundRGB: nil + ) + } + + init( + background: Color, + primaryText: Color, + secondaryText: Color, + bodyText: Color, + secondaryTextStyle: BleedTextStyle, + tertiaryTextStyle: BleedTextStyle, + bodyTextStyle: BleedTextStyle, + controlSecondaryTextStyle: BleedTextStyle, + hairline: Color, + controlTint: Color, + controlLabel: Color, + colorScheme: ColorScheme, + backgroundRGB: BleedRGB? + ) { + self.background = background + self.primaryText = primaryText + self.secondaryText = secondaryText + self.bodyText = bodyText + self.secondaryTextStyle = secondaryTextStyle + self.tertiaryTextStyle = tertiaryTextStyle + self.bodyTextStyle = bodyTextStyle + self.controlSecondaryTextStyle = controlSecondaryTextStyle + self.hairline = hairline + self.controlTint = controlTint + self.controlLabel = controlLabel + self.colorScheme = colorScheme + self.backgroundRGB = backgroundRGB + } + + /// Builds the palette from an extracted bleed color. + init(base: BleedRGB, settings: BleedSettings = .default) { + let background = Self.adjustedBackground(base, settings: settings) + let text = BleedRGB.white + let onBackground = Self.textStyles(text, on: background, settings: settings) + + // Controls: a lifted pill with a touch more chroma, unless that would drop the label + // below 4.5:1, in which case the pill moves the other way. + let tint = Self.controlTint(for: OKLab(background), text: text) + // Text on a surface filled with that tint is solved against the tint, not the page. + let onTint = Self.textStyles(text, on: tint, settings: settings) + + self.init( + background: background.color, + primaryText: text.color, + secondaryText: onBackground.flatSecondary.color, + bodyText: onBackground.flatBody.color, + secondaryTextStyle: onBackground.secondary, + tertiaryTextStyle: onBackground.tertiary, + bodyTextStyle: onBackground.body, + controlSecondaryTextStyle: onTint.secondary, + hairline: text.color.opacity(0.18), + controlTint: tint.color, + controlLabel: text.color, + colorScheme: .dark, + backgroundRGB: background + ) + } + + /// The text styles for `text` on `surface`: vibrant where it stays legible (3:1 for secondary + /// and tertiary metadata, 4.5:1 for body paragraphs), else flat at the lowest opacity that does. + private struct TextStyles { + let secondary: BleedTextStyle + let tertiary: BleedTextStyle + let body: BleedTextStyle + let flatSecondary: BleedTextStyle + let flatBody: BleedTextStyle + } + + private static func textStyles(_ text: BleedRGB, on surface: BleedRGB, settings: BleedSettings) -> TextStyles { + let secondaryOpacity = flatOpacity(text, on: surface, from: secondaryOpacityFloor, minimumContrast: 4.5) + let tertiaryOpacity = flatOpacity(text, on: surface, from: tertiaryOpacityFloor, minimumContrast: 3) + let bodyOpacity = flatOpacity(text, on: surface, from: bodyOpacityFloor, step: 0.05, minimumContrast: 4.5) + let flatSecondary = BleedTextStyle(color: text.color.opacity(secondaryOpacity)) + let flatTertiary = BleedTextStyle(color: text.color.opacity(tertiaryOpacity)) + let flatBody = BleedTextStyle(color: text.color.opacity(bodyOpacity)) + + var vibrantSecondary: BleedTextStyle? + var vibrantTertiary: BleedTextStyle? + var vibrantBody: BleedTextStyle? + if settings.vibrantText { + let opacity = settings.vibrantOpacity + vibrantSecondary = vibrantStyle(text, on: surface, opacities: [opacity], minimumContrast: 3) + // Tertiary starts quieter than secondary and climbs back toward it until it reads. + vibrantTertiary = vibrantStyle( + text, + on: surface, + opacities: stride(from: opacity * vibrantTertiaryScale, to: opacity, by: 0.02) + [opacity], + minimumContrast: 3 + ) + // Body copy starts a step above secondary and climbs until it reaches 4.5:1. + vibrantBody = vibrantStyle( + text, + on: surface, + opacities: stride(from: min(opacity + vibrantBodyLift, 1), through: 1, by: 0.02), + minimumContrast: 4.5 + ) + } + return TextStyles( + secondary: vibrantSecondary ?? flatSecondary, + tertiary: vibrantTertiary ?? flatTertiary, + body: vibrantBody ?? flatBody, + flatSecondary: flatSecondary, + flatBody: flatBody + ) + } + + // MARK: - Text + + /// The lowest opacity from `floor` up (in `step`s) at which `text` keeps `minimumContrast` + /// against `background` when composited normally; 1 if none does. + private static func flatOpacity( + _ text: BleedRGB, + on background: BleedRGB, + from floor: Double, + step: Double = 0.02, + minimumContrast: Double + ) -> Double { + stride(from: floor, through: 1.0, by: step).first { opacity in + background.blended(with: text, opacity: opacity).contrast(with: background) >= minimumContrast + } ?? 1 + } + + /// Vibrant `text` (plus-lighter) at the first of `opacities` whose effective color, the blend + /// result on `background`, keeps `minimumContrast`; `nil` if none does. + private static func vibrantStyle( + _ text: BleedRGB, + on background: BleedRGB, + opacities: some Sequence, + minimumContrast: Double + ) -> BleedTextStyle? { + let opacity = opacities.first { opacity in + background.blended(with: text, opacity: opacity, mode: .plusLighter).contrast(with: background) >= minimumContrast + } + return opacity.map { BleedTextStyle(color: text.color.opacity($0), blendMode: .plusLighter) } + } + + // MARK: - Adjustment + + private static func controlTint(for lab: OKLab, text: BleedRGB) -> BleedRGB { + let step = 0.1 + for delta in [step, -step * 0.8] { + let candidate = OKLab( + lightness: min(max(lab.l + delta, 0), 1), + chroma: lab.chroma * 1.15, + hue: lab.hue + ).gamutMappedRGB + if candidate.contrast(with: text) >= 4.5 { + return candidate + } + } + return OKLab(lightness: lab.l, chroma: lab.chroma * 1.15, hue: lab.hue).gamutMappedRGB + } + + /// The warm hue near-neutral images drift to, so a white plate or gray stone reads as a soft + /// taupe instead of flat, muddy gray. + private static let warmNeutralHue = 75.0 + /// The lowest opacity for flat secondary text. + private static let secondaryOpacityFloor = 0.72 + /// The lowest opacity for flat tertiary text, held to 3:1 rather than 4.5:1. + private static let tertiaryOpacityFloor = 0.5 + /// The lowest opacity for flat body paragraphs. + private static let bodyOpacityFloor = 0.8 + /// Vibrant tertiary text starts at this fraction of the vibrant secondary opacity. + private static let vibrantTertiaryScale = 0.75 + /// Vibrant body text starts this much above the vibrant secondary opacity. + private static let vibrantBodyLift = 0.1 + private static let neutralChromaThreshold = 0.04 + + /// The deep background for an extracted color: near-neutrals warmed, lightness pulled toward + /// the dark target, chroma boosted under a lightness-aware ceiling. + static func adjustedBackground(_ base: BleedRGB, settings: BleedSettings) -> BleedRGB { + let lab = OKLab(base) + var lightness = lab.l + var chroma = lab.chroma + var hue = lab.hue + + // 1. Near-neutral → gentle warm neutral, blended in a/b space so a cool gray drifts + // straight to warm instead of swinging through pink. + if settings.neutralWarmth > 0, chroma < neutralChromaThreshold { + let neutrality = 1 - chroma / neutralChromaThreshold // 1 = pure gray + let amount = min(1, neutrality * settings.neutralWarmth) + let warm = OKLab(lightness: lightness, chroma: 0.022 * settings.neutralWarmth, hue: warmNeutralHue) + let a = lab.a + (warm.a - lab.a) * amount + let b = lab.b + (warm.b - lab.b) * amount + let blended = OKLab(l: lightness, a: a, b: b) + chroma = blended.chroma + hue = blended.hue + } + + // 2. Lightness moves only part of the way to the target (`lightnessPull`): the bleed must + // continue the image tonally or the seam shows as a fog band. + lightness += (settings.lightnessDark - lightness) * min(max(settings.lightnessPull, 0), 1) + // 3. Guard rails: deep enough for white text and to hand off into the dark page. + lightness = min(max(lightness, 0.12), 0.55) + + // 4. Saturation, with a loose lightness-aware ceiling so deep tones don't turn neon. + chroma *= settings.saturation + chroma = min(chroma, 0.06 + 0.4 * lightness) + + return OKLab(lightness: lightness, chroma: chroma, hue: hue).gamutMappedRGB + } +} + +extension BleedPalette { + /// Secondary text on the bleed, vibrant when the palette keeps 3:1, else flat. + public var secondaryStyle: AnyShapeStyle { secondaryTextStyle.shapeStyle } + /// Quieter labels than secondary, such as a caption under a value; vibrant or flat, at least 3:1. + public var tertiaryStyle: AnyShapeStyle { tertiaryTextStyle.shapeStyle } + /// Paragraph text on the bleed; vibrant or flat, at least 4.5:1. + public var bodyStyle: AnyShapeStyle { bodyTextStyle.shapeStyle } + /// Secondary text drawn on a surface filled with `controlTint`, solved against the tint rather + /// than the page; use it for any text on a tile or card. + public var controlSecondaryStyle: AnyShapeStyle { controlSecondaryTextStyle.shapeStyle } +} + +extension EnvironmentValues { + /// The palette of the nearest `.bleedPalette(key:image:settings:)` ancestor. + @Entry public var bleedPalette: BleedPalette = .placeholder +} diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedPaletteModifier.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedPaletteModifier.swift new file mode 100644 index 000000000..24a3dc956 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedPaletteModifier.swift @@ -0,0 +1,43 @@ +// +// BleedPaletteModifier.swift +// FlipcashUI +// + +import SwiftUI + +/// Resolves the bleed palette for an image and provides it to descendants as `\.bleedPalette`. +struct BleedPaletteModifier: ViewModifier { + + let key: String? + let image: UIImage? + let settings: BleedSettings + + /// The last color this subtree resolved, held while a better image for the same subject + /// (a blurhash giving way to the real bytes) is still being read. + @State private var heldColor: BleedRGB? + + func body(content: Content) -> some View { + let store = BleedColorStore.shared + let color = key.flatMap { store.color(for: $0) } ?? heldColor + let palette = color.map { store.palette(for: $0, settings: settings) } ?? .placeholder + + content + .environment(\.bleedPalette, palette) + .task(id: key) { + guard let key, let image else { return } + if let resolved = await store.resolve(key, image: image) { + heldColor = resolved + } + } + } +} + +extension View { + + /// Provides `\.bleedPalette` for `image` to this subtree, computing its color asynchronously + /// and caching it under `key`, which must change whenever the image's pixels do. With a nil + /// `key` or `image` the subtree gets the placeholder palette. Draws nothing itself. + public func bleedPalette(key: String?, image: UIImage?, settings: BleedSettings = .default) -> some View { + modifier(BleedPaletteModifier(key: key, image: image, settings: settings)) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedRGB.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedRGB.swift new file mode 100644 index 000000000..3705941e1 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedRGB.swift @@ -0,0 +1,66 @@ +// +// BleedRGB.swift +// FlipcashUI +// + +import SwiftUI + +/// A gamma-encoded sRGB color (components 0…1), the currency of the bleed system. +nonisolated struct BleedRGB: Hashable, Sendable { + var red: Double + var green: Double + var blue: Double + + /// The color as a SwiftUI `Color` in sRGB. + var color: Color { Color(.sRGB, red: red, green: green, blue: blue) } + + /// WCAG relative luminance. + var relativeLuminance: Double { + 0.2126 * Self.linear(red) + 0.7152 * Self.linear(green) + 0.0722 * Self.linear(blue) + } + + /// WCAG contrast ratio against another color (1…21). + func contrast(with other: BleedRGB) -> Double { + let a = relativeLuminance + 0.05 + let b = other.relativeLuminance + 0.05 + return max(a, b) / min(a, b) + } + + /// Source-over blend of `top` at `opacity` onto `self`, in gamma space like UIKit compositing. + func blended(with top: BleedRGB, opacity: Double) -> BleedRGB { + BleedRGB( + red: red + (top.red - red) * opacity, + green: green + (top.green - green) * opacity, + blue: blue + (top.blue - blue) * opacity + ) + } + + /// `top` at `opacity` drawn onto `self` with `.plusLighter` (per channel `D + a·S`, clamped), in + /// gamma space like Core Animation; any other mode is a source-over blend. + func blended(with top: BleedRGB, opacity: Double, mode: BlendMode) -> BleedRGB { + switch mode { + case .plusLighter: + BleedRGB( + red: min(1, red + top.red * opacity), + green: min(1, green + top.green * opacity), + blue: min(1, blue + top.blue * opacity) + ) + default: + blended(with: top, opacity: opacity) + } + } + + static let white = BleedRGB(red: 1, green: 1, blue: 1) + + // MARK: Transfer functions + + /// The linear-light value of a gamma-encoded component. + static func linear(_ encoded: Double) -> Double { + encoded <= 0.04045 ? encoded / 12.92 : pow((encoded + 0.055) / 1.055, 2.4) + } + + /// The gamma-encoded value of a linear-light component. + static func encoded(_ linear: Double) -> Double { + linear <= 0.0031308 ? linear * 12.92 : 1.055 * pow(linear, 1 / 2.4) - 0.055 + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedSettings.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedSettings.swift new file mode 100644 index 000000000..1ac5cd7c6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedSettings.swift @@ -0,0 +1,44 @@ +// +// BleedSettings.swift +// FlipcashUI +// + +import Foundation + +/// The inputs of the dark bleed palette, defaulting to the values tuned in the Recipes prototype +/// (commit 6e2c13d). +public nonisolated struct BleedSettings: Hashable, Sendable { + + /// Scales the image's own chroma before the lightness-aware ceiling. + public var saturation: Double = 1.2 + /// The OKLab lightness the background is pulled toward. + public var lightnessDark: Double = 0.1 + /// How far toward `lightnessDark` the extracted color moves, 0…1. + public var lightnessPull: Double = 0.6 + /// How strongly a near-neutral image drifts to a warm neutral. + public var neutralWarmth: Double = 1 + /// Whether secondary, tertiary and body text are vibrant (plus-lighter) rather than flat. + public var vibrantText = true + /// The opacity of vibrant secondary text. + public var vibrantOpacity: Double = 0.6 + + /// The tuned defaults. + public static let `default` = BleedSettings() + + /// Settings with the tuned defaults for everything not passed. + public init( + saturation: Double = 1.2, + lightnessDark: Double = 0.1, + lightnessPull: Double = 0.6, + neutralWarmth: Double = 1, + vibrantText: Bool = true, + vibrantOpacity: Double = 0.6 + ) { + self.saturation = saturation + self.lightnessDark = lightnessDark + self.lightnessPull = lightnessPull + self.neutralWarmth = neutralWarmth + self.vibrantText = vibrantText + self.vibrantOpacity = vibrantOpacity + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedTextStyle.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedTextStyle.swift new file mode 100644 index 000000000..3586018eb --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedTextStyle.swift @@ -0,0 +1,24 @@ +// +// BleedTextStyle.swift +// FlipcashUI +// + +import SwiftUI + +/// A text color on the bleed and how it composites onto it. +/// +/// Vibrant text is white drawn with `.plusLighter`, so it adds to the color behind it channel by +/// channel and the bleed's hue shows through instead of washing out to gray; flat text is the color +/// at its opacity, composited normally. Vibrant text only works drawn straight onto the bleed: +/// inside a `.compositingGroup()`, `.drawingGroup()` or a glass effect it composites against that +/// layer instead, and a run inside a concatenated `Text` loses the blend, so use the flat color there. +nonisolated struct BleedTextStyle: Hashable, Sendable { + /// White at the style's opacity. + var color: Color + var blendMode: BlendMode = .normal + + /// The style for `foregroundStyle(_:)`. + var shapeStyle: AnyShapeStyle { + blendMode == .normal ? AnyShapeStyle(color) : AnyShapeStyle(color.blendMode(blendMode)) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/EasedStops.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/EasedStops.swift new file mode 100644 index 000000000..cf0e0a42e --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/EasedStops.swift @@ -0,0 +1,31 @@ +// +// EasedStops.swift +// FlipcashUI +// + +import SwiftUI + +/// Gradient stops that fade a color in along a smootherstep curve, so there is no visible band +/// where the ramp meets the flat color the way a two-stop linear gradient shows one. +nonisolated enum EasedStops { + + /// Stops that take `color` from 0% opacity at `start` to 100% at `end`, both 0…1 along the gradient. + static func fade(to color: Color, start: Double = 0, end: Double = 1, count: Int = 14) -> [Gradient.Stop] { + let start = min(max(start, 0), 1) + let end = max(min(max(end, 0), 1), start + 0.001) + var stops: [Gradient.Stop] = [] + if start > 0 { stops.append(.init(color: color.opacity(0), location: 0)) } + for index in 0...count { + let t = Double(index) / Double(count) + stops.append(.init(color: color.opacity(smootherstep(t)), location: start + (end - start) * t)) + } + if end < 1 { stops.append(.init(color: color, location: 1)) } + return stops + } + + /// The smootherstep ease of `t`, clamped to 0…1. + static func smootherstep(_ t: Double) -> Double { + let t = min(max(t, 0), 1) + return t * t * t * (t * (t * 6 - 15) + 10) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/OKLab.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/OKLab.swift new file mode 100644 index 000000000..d45d8453d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/OKLab.swift @@ -0,0 +1,95 @@ +// +// OKLab.swift +// FlipcashUI +// + +import Foundation + +/// OKLab / OKLCH (Björn Ottosson), a perceptual space where lightness and chroma behave, so the +/// palette can clamp lightness and scale saturation without hue shifts or muddy results. +nonisolated struct OKLab: Hashable, Sendable { + var l: Double + var a: Double + var b: Double + + /// A color from its OKLab coordinates. + init(l: Double, a: Double, b: Double) { + self.l = l + self.a = a + self.b = b + } + + /// The color of linear-light sRGB components. + init(linearRed r: Double, green g: Double, blue b: Double) { + let lms0 = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) + let lms1 = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) + let lms2 = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) + l = 0.2104542553 * lms0 + 0.7936177850 * lms1 - 0.0040720468 * lms2 + a = 1.9779984951 * lms0 - 2.4285922050 * lms1 + 0.4505937099 * lms2 + self.b = 0.0259040371 * lms0 + 0.7827717662 * lms1 - 0.8086757660 * lms2 + } + + /// The color of a gamma-encoded sRGB value. + init(_ rgb: BleedRGB) { + self.init( + linearRed: BleedRGB.linear(rgb.red), + green: BleedRGB.linear(rgb.green), + blue: BleedRGB.linear(rgb.blue) + ) + } + + /// Linear-light sRGB components, which fall outside 0…1 when the color is out of gamut. + var linearRGB: (red: Double, green: Double, blue: Double) { + let lms0 = pow(l + 0.3963377774 * a + 0.2158037573 * b, 3) + let lms1 = pow(l - 0.1055613458 * a - 0.0638541728 * b, 3) + let lms2 = pow(l - 0.0894841775 * a - 1.2914855480 * b, 3) + return ( + 4.0767416621 * lms0 - 3.3077115913 * lms1 + 0.2309699292 * lms2, + -1.2684380046 * lms0 + 2.6097574011 * lms1 - 0.3413193965 * lms2, + -0.0041960863 * lms0 - 0.7034186147 * lms1 + 1.7076147010 * lms2 + ) + } + + /// Whether the color fits inside sRGB. + var isInGamut: Bool { + let rgb = linearRGB + let range = -0.0001...1.0001 + return range.contains(rgb.red) && range.contains(rgb.green) && range.contains(rgb.blue) + } + + /// The distance from neutral gray. + var chroma: Double { (a * a + b * b).squareRoot() } + + /// The hue in degrees, 0…360. + var hue: Double { + let degrees = atan2(b, a) * 180 / .pi + return degrees < 0 ? degrees + 360 : degrees + } + + /// A color from OKLCH coordinates, with `hue` in degrees. + init(lightness: Double, chroma: Double, hue: Double) { + let radians = hue * .pi / 180 + self.init(l: lightness, a: chroma * cos(radians), b: chroma * sin(radians)) + } + + /// The color as sRGB, with chroma reduced (lightness and hue kept) until it fits the gamut. + var gamutMappedRGB: BleedRGB { + var mapped = self + if !mapped.isInGamut { + let hue = self.hue + var low = 0.0 + var high = chroma + for _ in 0..<18 { + let mid = (low + high) / 2 + if OKLab(lightness: l, chroma: mid, hue: hue).isInGamut { low = mid } else { high = mid } + } + mapped = OKLab(lightness: l, chroma: low, hue: hue) + } + let rgb = mapped.linearRGB + return BleedRGB( + red: BleedRGB.encoded(min(max(rgb.red, 0), 1)), + green: BleedRGB.encoded(min(max(rgb.green, 0), 1)), + blue: BleedRGB.encoded(min(max(rgb.blue, 0), 1)) + ) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/ObservableBox.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/ObservableBox.swift new file mode 100644 index 000000000..81dab3e7b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/ObservableBox.swift @@ -0,0 +1,18 @@ +// +// ObservableBox.swift +// FlipcashUI +// + +import Observation + +/// A single observable value, so a cache that keeps one box per key invalidates a view only when +/// its own entry changes rather than on every write to the cache. +@Observable +final class ObservableBox { + var value: Value? + + /// A box holding `value`, empty by default. + init(_ value: Value? = nil) { + self.value = value + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/FontBook.swift b/FlipcashUI/Sources/FlipcashUI/Theme/FontBook.swift index 47f4c25c5..e62ca4176 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/FontBook.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/FontBook.swift @@ -66,31 +66,27 @@ extension UIFont { } public static func `default`(size: CGFloat, weight: Weight = .regular) -> UIFont { - UIFont(name: avenir(weight: weight), size: size)! + .systemFont(ofSize: size, weight: appWeight(weight)) } - /// The app face at `weight`, slanted to stand in for an italic, which the bundled Avenir lacks. + /// The app face at `weight`, slanted to stand in for an italic. public static func defaultOblique(size: CGFloat, weight: Weight = .regular) -> UIFont { - oblique(named: avenir(weight: weight), size: size) + oblique(.default(size: size, weight: weight), size: size) } - fileprivate static func oblique(named name: String, size: CGFloat) -> UIFont { + fileprivate static func oblique(_ font: UIFont, size: CGFloat) -> UIFont { // Skia's fake-italic skew, so the slant matches what Compose synthesizes on Android. let skew = CGAffineTransform(a: 1, b: 0, c: 0.25, d: 1, tx: 0, ty: 0) - let descriptor = UIFont(name: name, size: size)!.fontDescriptor.withMatrix(skew) + let descriptor = font.fontDescriptor.withMatrix(skew) return UIFont(descriptor: descriptor, size: size) } - private static func avenir(weight: Weight) -> String { + /// The app's three steps on SF: the bold weights land on Semibold, where Avenir's Demi sat. + private static func appWeight(_ weight: Weight) -> Weight { switch weight { - case .ultraLight, .thin, .light, .regular: - return "AvenirNextLTPro-Regular" - case .medium: - return "AvenirNextLTPro-Medium" - case .semibold, .bold, .heavy, .black: - return "AvenirNextLTPro-Demi" - default: - fatalError("[FontBook] Failed to initialize UIKit app font. Unsupported font weight requested: \(weight)") + case .ultraLight, .thin, .light, .regular: .regular + case .medium: .medium + default: .semibold } } } @@ -106,19 +102,20 @@ extension Font { } public static func `default`(size: CGFloat, weight: Weight = .regular) -> Font { - .custom(avenir(weight: weight), size: size) + // `.custom(_:size:)` scaled with the body style; this keeps SF on Dynamic Type the same way. + #if canImport(UIKit) + .system(size: UIFontMetrics(forTextStyle: .body).scaledValue(for: size), weight: appWeight(weight)) + #else + .system(size: size, weight: appWeight(weight)) + #endif } - - private static func avenir(weight: Weight) -> String { + + /// The app's three steps on SF: the bold weights land on Semibold, where Avenir's Demi sat. + private static func appWeight(_ weight: Weight) -> Weight { switch weight { - case .ultraLight, .thin, .light, .regular: - return "AvenirNextLTPro-Regular" - case .medium, .semibold: - return "AvenirNextLTPro-Medium" - case .bold, .heavy, .black: - return "AvenirNextLTPro-Demi" - default: - fatalError("[FontBook] Failed to initialize SwiftUI app font. Unsupported font weight requested: \(weight)") + case .ultraLight, .thin, .light, .regular: .regular + case .medium: .medium + default: .semibold } } } @@ -133,7 +130,12 @@ extension Font { // A `Font` built from a `UIFont` doesn't track Dynamic Type, so the size is scaled here. let traits = UITraitCollection(preferredContentSizeCategory: UIContentSizeCategory(dynamicTypeSize)) let scaledSize = UIFontMetrics(forTextStyle: .body).scaledValue(for: size, compatibleWith: traits) - return Font(UIFont.oblique(named: avenir(weight: weight), size: scaledSize)) + let uiWeight: UIFont.Weight = switch weight { + case .medium: .medium + case .semibold, .bold, .heavy, .black: .semibold + default: .regular + } + return Font(UIFont.defaultOblique(size: scaledSize, weight: uiWeight)) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Icon.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Icon.swift new file mode 100644 index 000000000..faacfb202 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Icon.swift @@ -0,0 +1,146 @@ +// +// Image+Icon.swift +// FlipcashUI +// + +import SwiftUI +import UIKit + +extension Image { + /// A Central icon from the FlipcashUI catalog, drawn as a template image tinted by the caller. + /// It's decorative, so an icon-only control names itself with `.accessibilityLabel`. + public static func icon(_ icon: Icon) -> Image { + Image(decorative: icon.rawValue, bundle: Bundle.module) + } + + /// Sizes an icon to a square frame, since asset icons ignore `.font` the way SF Symbols don't. + public func iconSize(_ size: CGFloat) -> some View { + resizable() + .scaledToFit() + .frame(width: size, height: size) + } +} + +extension UIImage { + /// A Central icon from the FlipcashUI catalog as a template `UIImage`, for UIKit menus and cells. + public static func icon(_ icon: Icon) -> UIImage { + UIImage(named: icon.rawValue, in: Bundle.module, compatibleWith: nil)! + .withRenderingMode(.alwaysTemplate) + } +} + +// MARK: - Icon - + +/// The app's icon set: Central Icons (2px stroke, rounded), outlined unless the case says `Filled`. +public enum Icon: String, CaseIterable, Sendable { + case airplane = "central/Airplane" + case archive = "central/Archive" + case arrowBottomTop = "central/ArrowBottomTop" + case arrowBoxLeft = "central/ArrowBoxLeft" + case arrowCornerLeftDown = "central/ArrowCornerLeftDown" + case arrowDown = "central/ArrowDown" + case arrowInbox = "central/ArrowInbox" + case arrowLeft = "central/ArrowLeft" + case arrowRight = "central/ArrowRight" + case arrowRotateClockwise = "central/ArrowRotateClockwise" + case arrowShareLeft = "central/ArrowShareLeft" + case arrowShareLeftFilled = "central/ArrowShareLeftFilled" + case arrowShareRight = "central/ArrowShareRight" + case arrowUp = "central/ArrowUp" + case arrowUpWall = "central/ArrowUpWall" + case arrowWallDown = "central/ArrowWallDown" + case arrowsRepeat = "central/ArrowsRepeat" + case at = "central/At" + case banknote1 = "central/Banknote1" + case bell2 = "central/Bell2" + case bell2Off = "central/Bell2Off" + case bellOff = "central/BellOff" + case bezierCurve = "central/BezierCurve" + case bubble2 = "central/Bubble2" + case bubbleAlert = "central/BubbleAlert" + case bubbleCrossed = "central/BubbleCrossed" + case bug = "central/Bug" + case camera1 = "central/Camera1" + case chainLink4 = "central/ChainLink4" + case chatBubbles = "central/ChatBubbles" + case chatBubblesFilled = "central/ChatBubblesFilled" + case checklist = "central/Checklist" + case checkmark1 = "central/Checkmark1" + case chevronDownMedium = "central/ChevronDownMedium" + case chevronLeftMedium = "central/ChevronLeftMedium" + case chevronRightMedium = "central/ChevronRightMedium" + case circleArrowDown = "central/CircleArrowDown" + case circleBanSign = "central/CircleBanSign" + case circleCheck = "central/CircleCheck" + case circleDotted = "central/CircleDotted" + case circleInfo = "central/CircleInfo" + case circleMinus = "central/CircleMinus" + case circlePerson = "central/CirclePerson" + case circlePlus = "central/CirclePlus" + case circleX = "central/CircleX" + case circleXFilled = "central/CircleXFilled" + case clipboard = "central/Clipboard" + case clock = "central/Clock" + case coins = "central/Coins" + case coinsAdd = "central/CoinsAdd" + case creditCard1 = "central/CreditCard1" + case crossMedium = "central/CrossMedium" + case crown = "central/Crown" + case dotGrid1x3Horizontal = "central/DotGrid1x3Horizontal" + case dotGrid1x3Vertical = "central/DotGrid1x3Vertical" + case email1 = "central/Email1" + case emojiAddReaction = "central/EmojiAddReaction" + case exclamationCircle = "central/ExclamationCircle" + case exclamationTriangle = "central/ExclamationTriangle" + case faceId = "central/FaceId" + case fileBend = "central/FileBend" + case fileDownload = "central/FileDownload" + case fileText = "central/FileText" + case fingerPrint1 = "central/FingerPrint1" + case flag1 = "central/Flag1" + case folder1 = "central/Folder1" + case gift1 = "central/Gift1" + case globe = "central/Globe" + case grid = "central/Grid" + case group1 = "central/Group1" + case group3 = "central/Group3" + case hand5Finger = "central/Hand5Finger" + case history = "central/History" + case images1 = "central/Images1" + case images3 = "central/Images3" + case invite = "central/Invite" + case key1 = "central/Key1" + case kikCode = "central/KikCode" + case kikCodeFilled = "central/KikCodeFilled" + case lightningBolt = "central/LightningBolt" + case lightningBoltOff = "central/LightningBoltOff" + case lock = "central/Lock" + case magnifyingGlass = "central/MagnifyingGlass" + case maintenance = "central/Maintenance" + case paperPlaneTopRight = "central/PaperPlaneTopRight" + case pencil2 = "central/Pencil2" + case people = "central/People" + case peopleCircle = "central/PeopleCircle" + case peopleCircleFilled = "central/PeopleCircleFilled" + case peopleGear = "central/PeopleGear" + case peopleIdCard = "central/PeopleIdCard" + case phone = "central/Phone" + case plusLarge = "central/PlusLarge" + case qrCode = "central/QrCode" + case questionmarkCircle = "central/QuestionmarkCircle" + case send = "central/Send" + case settingsGear2 = "central/SettingsGear2" + case settingsSliderThree = "central/SettingsSliderThree" + case shareOs = "central/ShareOs" + case squareBehindSquare1 = "central/SquareBehindSquare1" + case squareX = "central/SquareX" + case textSize = "central/TextSize" + case trashCan = "central/TrashCan" + case trophy = "central/Trophy" + case user = "central/User" + case userAdd = "central/UserAdd" + case volumeOff = "central/VolumeOff" + case wallet1 = "central/Wallet1" + case wallet5 = "central/Wallet5" + case wallet5Filled = "central/Wallet5Filled" +} diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayAction.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayAction.swift new file mode 100644 index 000000000..443597bda --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayAction.swift @@ -0,0 +1,232 @@ +// +// TrayAction.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI + +/// One of a tray's capsule buttons: its title, its weight, its progress, and what it does. +/// +/// A tray holds one or two, side by side with the secondary on the left, or stacked with the +/// primary on top when a title wouldn't fit whole in its half. The right-hand action keeps its +/// place across a flow's steps, and a change of title crossfades in place. +public struct TrayAction { + + /// How an action is weighted against the other action in its tray. + public enum Role: Hashable, Sendable { + /// The white capsule on the right: the action the tray exists for. + case primary + /// The quiet capsule on the left: the alternative, such as declining. + case secondary + /// The primary's place in a solid red capsule: an action that can't be undone. + case destructive + } + + let title: String + let role: Role + let state: ButtonState + let isEnabled: Bool + let action: () -> Void + + /// Creates an action; `state` shows progress through ``ButtonStateLabel`` and disables the + /// action while it is not `.normal`. + public init( + _ title: String, + role: Role = .primary, + state: ButtonState = .normal, + isEnabled: Bool = true, + action: @escaping () -> Void + ) { + self.title = title + self.role = role + self.state = state + self.isEnabled = isEnabled + self.action = action + } + + /// The action's place in the bar, left to right; the primary and the destructive share one, + /// so a step that swaps them keeps the same capsule. + var slot: Int { + switch role { + case .secondary: 0 + case .primary, .destructive: 1 + } + } + + /// Whether the action takes taps: enabled, and not showing progress. + var isInteractive: Bool { + isEnabled && state.isNormal + } + + /// `actions` in the bar's left-to-right order. + static func ordered(_ actions: [TrayAction]) -> [TrayAction] { + actions.sorted { $0.slot < $1.slot } + } +} + +/// A tray's actions side by side at equal widths, the secondary on the left, or stacked full +/// width, the primary on top, when a title wouldn't fit whole beside the other. +struct TrayActionBar: View { + + let actions: [TrayAction] + + /// Whether the flow last moved forward; a step back reverses the title's rise. + var movesForward = true + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + TrayActionLayout(spacing: TrayMetrics.actionGap) { + ForEach(TrayAction.ordered(actions), id: \.slot) { action in + Button(action: action.action) { + label(for: action) + } + .buttonStyle(TrayButtonStyle(role: action.role)) + .disabled(!action.isInteractive) + .transition(.opacity) + } + } + } + + @ViewBuilder + private func label(for action: TrayAction) -> some View { + switch action.state { + case .normal: + ZStack { + Text(action.title) + .lineLimit(1) + .minimumScaleFactor(0.8) + .id(action.title) + .transition(titleTransition) + } + case .loading, .success, .successText: + ZStack { + // Keeps the title's width, so progress doesn't unstack a bar the title stacked. + Text(action.title) + .lineLimit(1) + .hidden() + .accessibilityHidden(true) + ButtonStateLabel(action.title, state: action.state) + } + } + } + + // Old and new titles overlap in place: the old clears while the new lands in the same spot, so + // the capsule never reads as empty. + private var titleTransition: AnyTransition { + let moves = !reduceMotion + let sign: CGFloat = (movesForward || !TrayMotion.labelFollowsDirection) ? 1 : -1 + let rise = moves ? TrayMotion.labelRise * sign : 0 + let blur = moves ? TrayMotion.labelBlur : 0 + let scale = moves ? TrayMotion.labelScale : 1 + return .trayFade( + insertion: TrayFade(opacity: 0, blur: blur, scale: scale, offset: rise), + insertionAnimation: TrayMotion.labelIn.animation.delay(TrayMotion.labelInDelay), + removal: TrayFade(opacity: 0, blur: blur, scale: scale, offset: -rise), + removalAnimation: TrayMotion.labelOut.animation + ) + } +} + +/// Lays the bar's buttons, given left to right, side by side at equal widths when every one fits +/// whole in its share, and otherwise stacked full width with the last, the primary, on top. +private struct TrayActionLayout: Layout { + + let spacing: CGFloat + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + guard !subviews.isEmpty else { return .zero } + let width = proposal.width.flatMap { $0.isFinite ? $0 : nil } ?? sideBySideWidth(subviews) + if fitsSideBySide(subviews, in: width) { + let share = self.share(of: width, among: subviews) + let height = subviews.map { $0.sizeThatFits(ProposedViewSize(width: share, height: nil)).height }.max() ?? 0 + return CGSize(width: width, height: height) + } + let heights = subviews.map { $0.sizeThatFits(ProposedViewSize(width: width, height: nil)).height } + return CGSize(width: width, height: heights.reduce(0, +) + gaps(subviews)) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + guard !subviews.isEmpty else { return } + if fitsSideBySide(subviews, in: bounds.width) { + let share = self.share(of: bounds.width, among: subviews) + var x = bounds.minX + for subview in subviews { + subview.place(at: CGPoint(x: x, y: bounds.midY), anchor: .leading, proposal: ProposedViewSize(width: share, height: bounds.height)) + x += share + spacing + } + } else { + var y = bounds.minY + for subview in subviews.reversed() { + let height = subview.sizeThatFits(ProposedViewSize(width: bounds.width, height: nil)).height + subview.place(at: CGPoint(x: bounds.minX, y: y), proposal: ProposedViewSize(width: bounds.width, height: height)) + y += height + spacing + } + } + } + + /// The width that keeps every title whole at equal shares: the widest button's ideal width + /// for each. + private func sideBySideWidth(_ subviews: Subviews) -> CGFloat { + let widest = subviews.map { $0.sizeThatFits(.unspecified).width }.max() ?? 0 + return widest * CGFloat(subviews.count) + gaps(subviews) + } + + private func fitsSideBySide(_ subviews: Subviews, in width: CGFloat) -> Bool { + sideBySideWidth(subviews) <= width + } + + private func share(of width: CGFloat, among subviews: Subviews) -> CGFloat { + (width - gaps(subviews)) / CGFloat(subviews.count) + } + + private func gaps(_ subviews: Subviews) -> CGFloat { + spacing * CGFloat(max(subviews.count - 1, 0)) + } +} + +/// The tray's capsule: white with a dark label for the primary, solid red with a white label for +/// the destructive, and a 10% white wash for the secondary. +struct TrayButtonStyle: ButtonStyle { + + let role: TrayAction.Role + + @Environment(\.isEnabled) private var isEnabled + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + func makeBody(configuration: Configuration) -> some View { + let press = TrayMetrics.actionPress.resolved(reduceMotion: reduceMotion) + let isPressed = configuration.isPressed + return configuration.label + // SF Pro until type tokens are decided (Phase 2). + .font(.system(size: TrayMetrics.scaled(TrayMetrics.actionSize, .callout, dynamicTypeSize), weight: .semibold)) + .foregroundStyle(foreground) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, minHeight: TrayMetrics.actionHeight) + .background(fill, in: Capsule()) + .contentShape(Capsule()) + .scaleEffect(press == .scale && isPressed ? TrayMetrics.actionPressScale : 1) + .opacity(press == .dim && isPressed ? TrayMetrics.actionPressDim : 1) + .animation(.spring(duration: 0.2, bounce: 0), value: isPressed) + } + + private var foreground: Color { + guard isEnabled else { return .textMain.opacity(0.2) } + switch role { + case .primary: return .textAction + case .destructive, .secondary: return .textMain + } + } + + private var fill: Color { + switch role { + case .primary: isEnabled ? .action : .action.opacity(0.1) + case .destructive: isEnabled ? .bannerError : .bannerError.opacity(0.1) + // White 10% from the mockup; there is no raised-fill token yet. + case .secondary: .white.opacity(0.1) + } + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayAmountEntry.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayAmountEntry.swift new file mode 100644 index 000000000..ff22fd222 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayAmountEntry.swift @@ -0,0 +1,76 @@ +// +// TrayAmountEntry.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashCore + +/// The amount picker at tray size: the full-screen entry's amount, hint and keypad, left-aligned, +/// with the amount scaled down and the keypad running the tray's full width. +/// +/// The caller owns `amount`, the keypad's raw string, and parses it for its CTA; this view only +/// edits and draws it, with the full-screen entry's components and digit limits. +public struct TrayAmountEntry: View { + + @Binding private var amount: String + + private let currency: CurrencyCode + private let caption: String? + private let isCaptionError: Bool + + /// Creates an entry that edits `amount` within `currency`'s digit limits; `isCaptionError` draws the caption as an error. + public init(amount: Binding, currency: CurrencyCode, caption: String? = nil, isCaptionError: Bool = false) { + self._amount = amount + self.currency = currency + self.caption = caption + self.isCaptionError = isCaptionError + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + // The full-screen header's amount and hint (`EnterAmountHeader`), at the tray's size. + VStack(alignment: .leading, spacing: TrayAmountMetrics.amountToCaption) { + AmountField( + content: $amount, + defaultValue: .number("0"), + prefix: .none, + formatter: .fiat(currency: currency, minimumFractionDigits: 0), + suffix: nil, + font: .default(size: TrayAmountMetrics.amountSize, weight: .bold), + height: TrayAmountMetrics.amountHeight + ) + .foregroundStyle(amount.isEmpty ? Color.textTertiary : Color.textMain) + + if let caption { + Text(caption) + .font(.default(size: TrayAmountMetrics.captionSize)) + .foregroundStyle(isCaptionError ? Color.textError : Color.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + KeyPadView( + content: $amount, + configuration: currency.maximumFractionDigits > 0 ? .decimal() : .number(), + rules: KeyPadView.CurrencyRules(maxIntegerDigits: 9, maxDecimalDigits: currency.maximumFractionDigits) + ) + // The screen-coloured key tint would show on the lighter tray. + .environment(\.keyPadRestFill, .clear) + .padding(.horizontal, -TrayAmountMetrics.keypadSideBleed) + .padding(.top, TrayAmountMetrics.captionToKeypad) + } + } +} + +#Preview("Amount entry") { + @Previewable @State var amount = "" + TrayPage { + TrayAmountEntry(amount: $amount, currency: .usd, caption: "$1,250.00 available") + } + .padding(24) + .background(Color.backgroundMain) +} diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayAmountMetrics.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayAmountMetrics.swift new file mode 100644 index 000000000..318c7dcba --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayAmountMetrics.swift @@ -0,0 +1,23 @@ +// +// TrayAmountMetrics.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI + +/// The amount tray's sizes and gaps: the full-screen entry's, scaled to a tray. +@MainActor +enum TrayAmountMetrics { + + /// The amount's size; the full-screen entry's is 74. + static let amountSize: CGFloat = 60 + /// The amount's line, in the full-screen entry's 90 : 74 proportion. + static var amountHeight: CGFloat { (amountSize * 90 / 74).rounded(.up) } + static let amountToCaption: CGFloat = 4 + static let captionSize: CGFloat = 16 + static let captionToKeypad: CGFloat = 24 + /// How far the keypad reaches into the tray's side padding; the padding's 24 runs it edge to edge. + static let keypadSideBleed: CGFloat = 16 +} diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayCloseButton.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayCloseButton.swift new file mode 100644 index 000000000..091bc0386 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayCloseButton.swift @@ -0,0 +1,78 @@ +// +// TrayCloseButton.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI + +/// Central Icons' CrossMedium: two strokes across a 24-unit box, inset 6.5 on every side. +/// +/// The geometry is redrawn here rather than shipping the licensed SVG; stroke it with round caps. +nonisolated struct CrossMediumShape: Shape { + + /// The box the icon is drawn in, and how far its strokes sit inside it. + private static let box: CGFloat = 24 + private static let inset: CGFloat = 6.5 + + /// The share of the box the strokes span, for sizing the box from a wanted cross size. + static var extentRatio: CGFloat { (box - 2 * inset) / box } + + func path(in rect: CGRect) -> Path { + let scale = min(rect.width, rect.height) / Self.box + let low = Self.inset * scale + let high = (Self.box - Self.inset) * scale + let origin = CGPoint(x: rect.midX - Self.box * scale / 2, y: rect.midY - Self.box * scale / 2) + var path = Path() + path.move(to: CGPoint(x: origin.x + low, y: origin.y + low)) + path.addLine(to: CGPoint(x: origin.x + high, y: origin.y + high)) + path.move(to: CGPoint(x: origin.x + high, y: origin.y + low)) + path.addLine(to: CGPoint(x: origin.x + low, y: origin.y + high)) + return path + } +} + +/// The tray's X: the cross on a circle, with a 44 pt tap target. It goes back a step from the +/// second step on and dismisses on the root. +struct TrayCloseButton: View { + + let accessibilityLabel: String + let action: () -> Void + + var body: some View { + let size = TrayMetrics.closeSize + let crossBox = TrayMetrics.closeGlyphSize / CrossMediumShape.extentRatio + Button(action: action) { + CrossMediumShape() + .stroke(Color.textMain, style: StrokeStyle(lineWidth: TrayMetrics.closeStroke, lineCap: .round)) + .opacity(TrayMetrics.closeGlyphOpacity) + .frame(width: crossBox, height: crossBox) + .frame(width: size, height: size) + // A white wash; there is no token for it yet. + .background(Color.white.opacity(TrayMetrics.closeFillOpacity), in: Circle()) + .frame(width: TrayMetrics.closeHitSize, height: TrayMetrics.closeHitSize) + .contentShape(Rectangle()) + } + .buttonStyle(TrayDimPressStyle()) + // Lays out at the circle's size so the circle sits on the tray's padding; the tap target + // overhangs it. + .padding(-(TrayMetrics.closeHitSize - size) / 2) + .accessibilityLabel(accessibilityLabel) + } +} + +/// Dims a tray control while pressed, as the house's icon and card buttons do. +struct TrayDimPressStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .opacity(configuration.isPressed ? 0.6 : 1) + .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) + } +} + +#Preview("TrayCloseButton") { + TrayCloseButton(accessibilityLabel: "Close") {} + .padding(40) + .background(Color.backgroundSecondary) +} diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayFlow.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayFlow.swift new file mode 100644 index 000000000..dbcf642de --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayFlow.swift @@ -0,0 +1,59 @@ +// +// TrayFlow.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Observation + +/// The steps of one tray flow and the direction of the last move, which drive the back control +/// and the step transition. +@Observable @MainActor +public final class TrayFlow { + + /// Which way the flow last moved. + public enum Direction: Sendable { + case forward + case back + } + + /// The steps from the root to the current one; never empty. + public private(set) var path: [Step] + + /// Which way the flow last moved: forward for a push, back for a pop. + public private(set) var direction: Direction = .forward + + /// The step on screen. + public var current: Step { path[path.count - 1] } + + /// Whether there is a step to go back to. + public var canGoBack: Bool { path.count > 1 } + + /// Creates a flow showing `root`. + public init(root: Step) { + path = [root] + } + + /// Moves forward to `step`. + public func push(_ step: Step) { + direction = .forward + path.append(step) + } + + /// Returns to the previous step; does nothing on the root. + public func back() { + guard canGoBack else { return } + direction = .back + path.removeLast() + } + + /// Does what the tray's X does: goes back a step, or calls `dismiss` on the root. + public func close(dismiss: () -> Void) { + if canGoBack { + back() + } else { + dismiss() + } + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayIcon.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayIcon.swift new file mode 100644 index 000000000..e38ffa22b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayIcon.swift @@ -0,0 +1,64 @@ +// +// TrayIcon.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI + +/// The mark at a tray's top left: a Central icon tinted like the rest of the tray, or an image. +public enum TrayIcon { + /// A Central icon, drawn bare at the icon size in the tray's tint. + case central(Icon) + /// An image drawn as it is, such as a brand mark; template images and SF Symbols take the + /// tray's tint. + case image(Image) +} + +extension TrayIcon { + /// What makes two icons the same for a step change: the Central name, or nil for an image, + /// which can't be compared. + var identity: AnyHashable? { + switch self { + case .central(let icon): icon.rawValue + case .image: nil + } + } +} + +extension TrayIcon { + /// The mark a destructive or error tray shows: the exclamation circle. + public static var destructive: TrayIcon { .central(.exclamationCircle) } +} + +extension Image { + /// A Central icon in the 1.5 pt set: the large mark at a tray's top left, where the 2 pt set + /// reads heavy. + static func trayIcon(_ icon: Icon) -> Image { + Image(icon.rawValue.replacingOccurrences(of: "central/", with: "central-light/"), bundle: Bundle.module) + } +} + +/// A tray's icon at the tray's icon size. +struct TrayIconView: View { + + let icon: TrayIcon + let color: Color + + var body: some View { + let size = TrayMetrics.iconSize + Group { + switch icon { + case .central(let icon): + Image.trayIcon(icon) + .iconSize(size) + case .image(let image): + image + .iconSize(size) + } + } + .foregroundStyle(color) + .accessibilityHidden(true) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayMetrics.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayMetrics.swift new file mode 100644 index 000000000..e67d10c9a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayMetrics.swift @@ -0,0 +1,132 @@ +// +// TrayMetrics.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import UIKit + +/// The tray's layout, type and fill values: the Modals v2 mockup (Figma `552:811`), retuned on +/// the Dev iPhone in rounds 2–4. +@MainActor +enum TrayMetrics { + + // MARK: - Layout + + static let padding: CGFloat = 24 + static let iconToTitle: CGFloat = 12 + static let titleToBody: CGFloat = 12 + static let bodyToContent: CGFloat = 16 + static let contentToActions: CGFloat = 24 + static let iconSize: CGFloat = 64 + static let closeSize: CGFloat = 36 + /// The drawn extent of the close X, edge to edge. + static let closeGlyphSize: CGFloat = 12 + static let closeStroke: CGFloat = 2.5 + static let closeGlyphOpacity: Double = 1.0 + static let closeFillOpacity: Double = 0.05 + static let actionHeight: CGFloat = 54 + static let actionGap: CGFloat = 12 + /// The card's top corners on the floating sheet; 39.5 draws the system sheet's 38 at the + /// sheet's 0.96 scale. The bottom corners nest in the display's. + static let topRadius: CGFloat = 39.5 + + /// The edge-attached iOS 18 sheet's top corners, which the card matches. + static let edgeAttachedTopRadius: CGFloat = 10 + + /// The close control's tap target, wider than its drawn circle. + static let closeHitSize: CGFloat = 44 + + // MARK: - Type + + // SF Pro sizes until type tokens are decided (Phase 2). Each is the size at the default + // Dynamic Type category and scales with the text style it stands in for. + static let titleSize: CGFloat = 24 + /// A title in the header row, beside the X, for a step with no icon. + static let headerTitleSize: CGFloat = 20 + static let titleWeight: Font.Weight = .semibold + static let titleTracking: CGFloat = -0.22 + static let bodySize: CGFloat = 15 + static let bodyLineHeight: CGFloat = 20 + static let bodyOpacity: Double = 0.6 + static let actionSize: CGFloat = 16 + + /// `size` at `dynamicTypeSize`, scaled as `style` scales; `size` itself at the default + /// category. + static func scaled(_ size: CGFloat, _ style: UIFont.TextStyle, _ dynamicTypeSize: DynamicTypeSize) -> CGFloat { + let traits = UITraitCollection(preferredContentSizeCategory: UIContentSizeCategory(dynamicTypeSize)) + return UIFontMetrics(forTextStyle: style).scaledValue(for: size, compatibleWith: traits) + } + + /// The extra space between body lines that brings SF's natural line height up to the target + /// at `dynamicTypeSize`. + static func bodyLineSpacing(_ dynamicTypeSize: DynamicTypeSize) -> CGFloat { + let size = scaled(bodySize, .subheadline, dynamicTypeSize) + let lineHeight = scaled(bodyLineHeight, .subheadline, dynamicTypeSize) + return max(0, lineHeight - UIFont.systemFont(ofSize: size).lineHeight) + } + + // MARK: - Fill + + /// Whether the card is Liquid Glass (iOS 26+) or a material (iOS 18) instead of the flat fill. + static let usesGlass = false + static let flatOpacity: Double = 0.5 + /// Whether the system dims the screen behind the tray. + static let dimsBackground = true + + // MARK: - Rows + + static let rowHeight: CGFloat = 56 + static let rowRadius: CGFloat = 16 + static let rowGap: CGFloat = 8 + static let rowFillOpacity: Double = 0.06 + static let rowSelectedFillOpacity: Double = 0.14 + static let rowIconSize: CGFloat = 22 + static let rowTextSize: CGFloat = 17 + static let rowTextWeight: Font.Weight = .semibold + static let tileHeight: CGFloat = 64 + static let tileRadius: CGFloat = 16 + static let tileSelection: TrayTileSelection = .raised + static let rowPressScale: CGFloat = 0.98 + + // MARK: - Error fade + + /// The red at the error card's top edge. + static let errorFadeStrength: Double = 0.39 + /// How far down the error card the red reaches before it's gone, as a share of the card's height. + static let errorFadeLength: Double = 0.865 + /// How much darker the error card is than a standard tray, under the red. + static let errorFadeDarkness: Double = 0.53 + + // MARK: - Behavior + + static let actionPress: TrayActionPress = .scale + static let actionPressScale: CGFloat = 0.95 + static let actionPressDim: Double = 0.7 +} + +/// How a selected tile stands out. +enum TrayTileSelection { + /// The row's raised fill. + case raised + /// A white ring on the resting fill. + case outline + /// White, with a dark label, as the primary CTA. + case inverted +} + +/// How the CTA answers a press. +enum TrayActionPress { + case none, scale, dim + + /// The press to draw: Reduce Motion turns a scale into a dim, keeping feedback without + /// movement. + func resolved(reduceMotion: Bool) -> TrayActionPress { + switch self { + case .scale: reduceMotion ? .dim : .scale + case .none, .dim: self + } + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayMotion.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayMotion.swift new file mode 100644 index 000000000..8a2568380 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayMotion.swift @@ -0,0 +1,110 @@ +// +// TrayMotion.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI + +/// Every animation a tray runs, as ``ChatSpring`` values (the house spring model). +/// +/// The tray's card is drawn in SwiftUI inside an invisible sheet, so every motion here is ours, +/// the card's height included; the sheet's own edge never animates. +nonisolated enum TrayMotion { + + /// The card's height, and the chrome and page with it, as a step changes. + static let resize = ChatSpring(duration: 0.35, bounce: 0) + + /// The incoming page fading and unblurring into place. + static let contentIn = ChatSpring(duration: 0.27, bounce: 0) + + /// The outgoing page clearing; quicker than the entrance so the two never read as a muddle. + static let contentOut = ChatSpring(duration: 0.20, bounce: 0) + + /// The action's old title clearing; quicker than the new one arriving. + static let labelOut = ChatSpring(duration: 0.17, bounce: 0) + + /// The action's new title settling into place. + static let labelIn = ChatSpring(duration: 0.34, bounce: 0.1) + + /// How long the new title waits, so it lands as the old one clears. + static let labelInDelay: TimeInterval = 0.045 + + /// The blur a title carries at either end of its change. + static let labelBlur: CGFloat = 0 + + /// How far a title travels as it changes: the new one rises into place, the old one rises away. + static let labelRise: CGFloat = 0 + + /// The scale a title holds at either end of its change. + static let labelScale: CGFloat = 1 + + /// Whether a step back reverses the rise, so the title falls the way the flow went. + static let labelFollowsDirection = true + + /// How long the incoming page waits, so it lands as the outgoing one clears. + static let contentInDelay: TimeInterval = 0.05 + + /// The blur a page carries at either end of its crossfade. + static let pageBlur: CGFloat = 2 + + /// Whether a page scales as it crossfades. + static let pageScales = true + + /// The scale a leaving page shrinks to. + static let pageScaleOut: CGFloat = 0.96 + + /// The scale an arriving page grows from. + static let pageScaleIn: CGFloat = 0.96 +} + +extension ChatSpring { + /// The same spring as SwiftUI's value type, for reading positions along it. + var spring: Spring { Spring(duration: duration, bounce: bounce) } +} + +/// One end of a tray crossfade: faded, blurred, scaled about the centre and shifted. +/// +/// A collapsing end stops taking up height at once, without animating, and stops naming the tray: +/// a leaving page then leaves the region to size to the incoming page alone, on the resize spring. +struct TrayFade: ViewModifier { + + static let identity = TrayFade(opacity: 1, blur: 0, scale: 1) + + var opacity: Double + var blur: CGFloat + var scale: CGFloat + var offset: CGFloat = 0 + var collapses = false + + func body(content: Content) -> some View { + content + .scaleEffect(scale, anchor: .center) + .offset(y: offset) + .opacity(opacity) + .blur(radius: blur) + .frame(height: collapses ? 0 : nil, alignment: .top) + .animation(nil, value: collapses) + .transformPreference(TrayTitleKey.self) { title in + if collapses { title = nil } + } + } +} + +extension AnyTransition { + + /// A crossfade that arrives from `insertion` on `insertionAnimation` and leaves for `removal` + /// on `removalAnimation`. + static func trayFade( + insertion: TrayFade, + insertionAnimation: Animation, + removal: TrayFade, + removalAnimation: Animation + ) -> AnyTransition { + .asymmetric( + insertion: .modifier(active: insertion, identity: .identity).animation(insertionAnimation), + removal: .modifier(active: removal, identity: .identity).animation(removalAnimation) + ) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayOptionRow.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayOptionRow.swift new file mode 100644 index 000000000..2e46bc4fb --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayOptionRow.swift @@ -0,0 +1,109 @@ +// +// TrayOptionRow.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import UIKit + +/// One choice in a tray's list: an optional icon, a title, and a check while it is selected. A tap +/// plays a selection haptic on touch down. +/// +/// Stack several at ``TrayOptionRow/gap`` as a tray's content. A row either applies its choice +/// on tap or selects it for the tray's CTA to confirm; `isSelected` draws the latter. +public struct TrayOptionRow: View { + + private let title: String + private let icon: Icon? + private let isSelected: Bool + private let action: () -> Void + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + /// Creates a row; `isSelected` shows the check and the raised fill. + public init(_ title: String, icon: Icon? = nil, isSelected: Bool = false, action: @escaping () -> Void) { + self.title = title + self.icon = icon + self.isSelected = isSelected + self.action = action + } + + public var body: some View { + Button(action: action) { + HStack(spacing: 12) { + if let icon { + Image.icon(icon) + .iconSize(TrayMetrics.rowIconSize) + } + Text(title) + // SF Pro until type tokens are decided (Phase 2). + .font(.system(size: TrayMetrics.scaled(TrayMetrics.rowTextSize, .body, dynamicTypeSize), weight: TrayMetrics.rowTextWeight)) + .frame(maxWidth: .infinity, alignment: .leading) + Image.icon(.checkmark1) + .iconSize(20) + .opacity(isSelected ? 1 : 0) + } + .foregroundStyle(Color.textMain) + .padding(.horizontal, 16) + .frame(minHeight: TrayMetrics.rowHeight) + // Selection snaps, fill and check alike; only the press moves. + .background { + RoundedRectangle(cornerRadius: TrayMetrics.rowRadius, style: .continuous) + .fill(Color.white.opacity(isSelected ? TrayMetrics.rowSelectedFillOpacity : TrayMetrics.rowFillOpacity)) + } + .contentShape(.rect(cornerRadius: TrayMetrics.rowRadius, style: .continuous)) + // The release's transaction reaches the label as the selection lands; without this the + // tapped row fades its check and fill in. + .animation(nil, value: isSelected) + } + .buttonStyle(TrayRowPressStyle()) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + +/// A tray row or tile's press: a slight scale, or a dim under Reduce Motion, with the haptic on touch down. +struct TrayRowPressStyle: ButtonStyle { + + /// Kept prepared between taps, so fast typing gets its haptic on time. + @MainActor private static let selectionGenerator = UISelectionFeedbackGenerator() + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func makeBody(configuration: Configuration) -> some View { + let isPressed = configuration.isPressed + return configuration.label + .modifier(Press(isPressed: isPressed, reduceMotion: reduceMotion)) + // On touch down, as a physical control answers, not on release. + .onChange(of: isPressed) { _, isPressed in + if isPressed { + Self.selectionGenerator.selectionChanged() + Self.selectionGenerator.prepare() + } + } + } +} + +/// The press's scale or dim, animated on its own. +private struct Press: ViewModifier { + + let isPressed: Bool + let reduceMotion: Bool + + func body(content: Content) -> some View { + // Scoped to the press itself: a selection that lands as the finger lifts must not ride the + // release's spring. + content + .animation(.spring(duration: 0.2, bounce: 0)) { content in + content + .scaleEffect(!reduceMotion && isPressed ? TrayMetrics.rowPressScale : 1) + .opacity(reduceMotion && isPressed ? 0.7 : 1) + } + } +} + +extension TrayOptionRow { + /// The space between stacked rows. + @MainActor public static let gap: CGFloat = TrayMetrics.rowGap +} diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayOptionTile.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayOptionTile.swift new file mode 100644 index 000000000..94a81874b --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayOptionTile.swift @@ -0,0 +1,69 @@ +// +// TrayOptionTile.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI + +/// One choice in a tray's grid of short options, such as preset amounts: a centred label on a +/// rounded tile, raised while selected. +/// +/// Lay several out with ``TrayOptionTile/columns`` in a `LazyVGrid`. A tile behaves as +/// ``TrayOptionRow`` does: a selection snaps, and only the press moves. +public struct TrayOptionTile: View { + + private let title: String + private let isSelected: Bool + private let action: () -> Void + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + /// Creates a tile; `isSelected` draws it raised. + public init(_ title: String, isSelected: Bool = false, action: @escaping () -> Void) { + self.title = title + self.isSelected = isSelected + self.action = action + } + + public var body: some View { + let shape = RoundedRectangle(cornerRadius: TrayMetrics.tileRadius, style: .continuous) + Button(action: action) { + Text(title) + // SF Pro until type tokens are decided (Phase 2). + .font(.system(size: TrayMetrics.scaled(TrayMetrics.rowTextSize, .body, dynamicTypeSize), weight: TrayMetrics.rowTextWeight)) + .lineLimit(1) + .minimumScaleFactor(0.7) + .foregroundStyle(isSelected && TrayMetrics.tileSelection == .inverted ? Color.textAction : Color.textMain) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, minHeight: TrayMetrics.tileHeight) + .background(shape.fill(fill)) + .overlay { + if isSelected && TrayMetrics.tileSelection == .outline { + shape.strokeBorder(Color.textMain, lineWidth: 2) + } + } + .contentShape(shape) + .animation(nil, value: isSelected) + } + .buttonStyle(TrayRowPressStyle()) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + private var fill: Color { + guard isSelected else { return .white.opacity(TrayMetrics.rowFillOpacity) } + switch TrayMetrics.tileSelection { + case .raised: return .white.opacity(TrayMetrics.rowSelectedFillOpacity) + case .outline: return .white.opacity(TrayMetrics.rowFillOpacity) + case .inverted: return .action + } + } +} + +extension TrayOptionTile { + /// Three equal columns at the tray's row gap. + @MainActor public static var columns: [GridItem] { + Array(repeating: GridItem(.flexible(), spacing: TrayMetrics.rowGap), count: 3) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayPage.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayPage.swift new file mode 100644 index 000000000..a2271779d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayPage.swift @@ -0,0 +1,85 @@ +// +// TrayPage.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI + +/// One step's body in a tray: a title, an optional message, and optional content under them. +public struct TrayPage: View { + + private let title: String? + private let message: String? + private let content: Content? + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + /// Creates a page with `content` under the title and message. + public init(title: String, message: String? = nil, @ViewBuilder content: () -> Content) { + self.init(title: title, message: message, content: content()) + } + + /// Creates a page of an optional message and `content`, for a step whose title sits in the + /// tray's header (see ``TrayStack``'s `headerTitle`). + public init(message: String? = nil, @ViewBuilder content: () -> Content) { + self.init(title: nil, message: message, content: content()) + } + + /// Creates a page whose content may be absent, for ``Tray``. + init(title: String?, message: String?, content: Content?) { + self.title = title + self.message = message + self.content = content + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let title { + Text(title) + // SF Pro until type tokens are decided (Phase 2). + .font(.system(size: TrayMetrics.scaled(TrayMetrics.titleSize, .title2, dynamicTypeSize), weight: TrayMetrics.titleWeight)) + .tracking(TrayMetrics.titleTracking) + .foregroundStyle(Color.textMain) + .accessibilityAddTraits(.isHeader) + } + + if let message { + Text(message) + .font(.system(size: TrayMetrics.scaled(TrayMetrics.bodySize, .subheadline, dynamicTypeSize))) + .lineSpacing(TrayMetrics.bodyLineSpacing(dynamicTypeSize)) + // Dark-mode vibrancy: light ink added onto what's beneath it. + .foregroundStyle(Color.white.opacity(TrayMetrics.bodyOpacity)) + .blendMode(.plusLighter) + .padding(.top, title == nil ? 0 : TrayMetrics.titleToBody) + } + + if let content { + content + .padding(.top, title == nil && message == nil ? 0 : TrayMetrics.bodyToContent) + } + } + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .preference(key: TrayTitleKey.self, value: title) + } +} + +extension TrayPage where Content == EmptyView { + + /// Creates a page of a title and an optional message. + public init(title: String, message: String? = nil) { + self.init(title: title, message: message, content: nil) + } +} + +/// The title of the page on screen, which names the tray for VoiceOver and UI tests. +struct TrayTitleKey: PreferenceKey { + static let defaultValue: String? = nil + + static func reduce(value: inout String?, nextValue: () -> String?) { + value = nextValue() ?? value + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayPresentation.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayPresentation.swift new file mode 100644 index 000000000..fa3d54a74 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayPresentation.swift @@ -0,0 +1,153 @@ +// +// TrayPresentation.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI + +extension View { + + /// Presents a tray for `item` while it is non-nil: a floating panel sized to its content on + /// iOS 26, an edge-attached partial sheet on iOS 18. + /// + /// The card follows its content's height as steps change. With `isDismissable` false it can't + /// be dragged away and its root step shows no X. `onDismiss` runs however the tray leaves. + public func tray( + item: Binding, + isDismissable: Bool = true, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (Item) -> Content + ) -> some View { + sheet(item: item, onDismiss: onDismiss) { item in + TrayHost(isDismissable: isDismissable) { + content(item) + } + } + } + + /// Presents a tray while `isPresented` is true, as `tray(item:)` does for an item. + public func tray( + isPresented: Binding, + isDismissable: Bool = true, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping () -> Content + ) -> some View { + sheet(isPresented: isPresented, onDismiss: onDismiss) { + TrayHost(isDismissable: isDismissable, content: content) + } + } +} + +/// The sheet's root: hangs the tray's card from the sheet's bottom edge, measures it, and hands +/// the height to the UIKit seam, which keeps the sheet invisible and tall enough. +private struct TrayHost: View { + + let isDismissable: Bool + @ViewBuilder let content: Content + + @State private var cardHeight: CGFloat = 0 + /// The tallest the card can be, once the sheet has said; a taller page scrolls inside the card. + @State private var maximumCardHeight: CGFloat? + + /// The floating sheet's bottom safe area covers nothing, so the card sits on its bottom edge; + /// the iOS 18 sheet keeps the card's content above the home indicator. + private static var floatingEdges: Edge.Set { + if #available(iOS 26, *) { .bottom } else { [] } + } + + var body: some View { + // The root takes the sheet's size whatever the card's. A root sized by a card taller than + // the sheet is centred by the hosting controller, which moved the card by half the change + // on every grow; an overlay lets the card overhang instead. + Color.clear + .overlay(alignment: .bottom) { + content + // Its full height until the sheet's limit is known, then offered that limit. + .fixedSize(horizontal: false, vertical: maximumCardHeight == nil) + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + cardHeight = height + } + .environment(\.trayIsDismissable, isDismissable) + .frame(height: maximumCardHeight, alignment: .bottom) + } + .ignoresSafeArea(.container, edges: Self.floatingEdges) + .background { + TraySheetSeam( + cardHeight: cardHeight, + dimsBackground: TrayMetrics.dimsBackground, + onMaximumCardHeight: { maximumCardHeight = $0 } + ) + .frame(width: 0, height: 0) + .accessibilityHidden(true) + } + // The card draws its own surface; the sheet stays clear (see `TrayFill`). + .presentationBackground(.clear) + // Never changes, so SwiftUI never re-applies it over the seam's detent; it only shows + // if the seam can't reach the sheet. + .presentationDetents([.medium]) + .presentationDragIndicator(.hidden) + .interactiveDismissDisabled(!isDismissable) + } +} + +/// The tray card's surface: our own Liquid Glass (a material on iOS 18) or the flat fill, under +/// the error style's red fade. +/// +/// The sheet behind the card is clear. The seam clears the floating sheet's system glass through +/// the detent's background effect (iOS 26.1+), so the flat fill keeps a glass layer of its own +/// beneath it, as the system glass sat beneath it before; on iOS 18 a material stands in for the +/// glass. +struct TrayFill: View { + + let style: TrayStyle + + var body: some View { + if #available(iOS 26, *) { + let shape = ConcentricRectangle( + uniformTopCorners: .fixed(TrayMetrics.topRadius), + uniformBottomCorners: .concentric(minimum: .fixed(TrayMetrics.topRadius)) + ) + shape + .fill(flat ?? .clear) + .overlay { errorFade.clipShape(shape) } + .background { Color.clear.glassEffect(.regular, in: shape) } + } else { + // The edge-attached sheet: its rounded top, and a square bottom running under the home + // indicator. + let shape = UnevenRoundedRectangle( + topLeadingRadius: TrayMetrics.edgeAttachedTopRadius, + topTrailingRadius: TrayMetrics.edgeAttachedTopRadius, + style: .continuous + ) + shape + .fill(flat ?? .clear) + .overlay { errorFade.clipShape(shape) } + .background(.thinMaterial, in: shape) + .ignoresSafeArea(.container, edges: .bottom) + } + } + + /// The flat colour over the glass, or nil for glass alone. + private var flat: Color? { + TrayMetrics.usesGlass ? nil : Color.backgroundSecondary.opacity(TrayMetrics.flatOpacity) + } + + /// A darker card with red fading down into it from the top edge, for the error style. + @ViewBuilder + private var errorFade: some View { + if style.fadesRed { + ZStack { + Color.black.opacity(TrayMetrics.errorFadeDarkness) + LinearGradient( + colors: [Color.bannerError.opacity(TrayMetrics.errorFadeStrength), Color.bannerError.opacity(0)], + startPoint: .top, + endPoint: UnitPoint(x: 0.5, y: TrayMetrics.errorFadeLength) + ) + } + } + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TraySandbox.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TraySandbox.swift new file mode 100644 index 000000000..38e05272c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TraySandbox.swift @@ -0,0 +1,670 @@ +// +// TraySandbox.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if DEBUG +import SwiftUI +import FlipcashCore + +/// The tray prototype, standing in for the whole app when launched with `--tray-sandbox`: a +/// static Amount Entry look-alike with a menu of every kind of tray. +/// +/// A launch argument for the same reason as the motion sandbox: one `simctl launch` line puts the +/// device on the trays with no sign-in, so a tuning round starts in the same place every time. +/// `--tray-sandbox-open ` opens one kind at launch. DEBUG-only, so it can't ship. +public struct TraySandbox: View { + + /// Whether the app was launched with `--tray-sandbox`. + @MainActor public static var isRequested: Bool { + ProcessInfo.processInfo.arguments.contains("--tray-sandbox") + } + + /// Whether the deposit flow plays itself (`--tray-sandbox-autoplay`): open, forward to the end, + /// back to the root, close, so two recordings differ only by the code under test. + static var autoplays: Bool { + ProcessInfo.processInfo.arguments.contains("--tray-sandbox-autoplay") + } + + /// The kind named after `--tray-sandbox-open`, if any. + static var requestedKind: TrayKind? { + let arguments = ProcessInfo.processInfo.arguments + guard let index = arguments.firstIndex(of: "--tray-sandbox-open"), index + 1 < arguments.count else { return nil } + return TrayKind(rawValue: arguments[index + 1]) + } + + @State private var kind: TrayKind? + @State private var isShowingInfo = false + @State private var lastOpened: String? + @State private var lastDismissed: String? + + /// Creates the sandbox. + public init() {} + + public var body: some View { + if ProcessInfo.processInfo.arguments.contains("--tray-sandbox-cross") { + CrossComparison() + } else { + sandbox + } + } + + private var sandbox: some View { + AmountEntryBackdrop(lastDismissed: lastDismissed, open: open) + // Item-driven: every kind but the info tray. + .tray(item: $kind, isDismissable: kind?.isDismissable ?? true, onDismiss: { lastDismissed = lastOpened }) { kind in + kind.tray + } + // Bool-driven: the info tray. + .tray(isPresented: $isShowingInfo, onDismiss: { lastDismissed = lastOpened }) { + TrayKind.info.tray + } + .preferredColorScheme(.dark) + .task { + if Self.autoplays { + try? await Task.sleep(for: .seconds(1)) + kind = .depositFlow + } else if let requested = Self.requestedKind { + try? await Task.sleep(for: .seconds(0.5)) + open(requested) + } + } + } + + private func open(_ kind: TrayKind) { + lastOpened = kind.name + switch kind { + case .info: isShowingInfo = true + case .decision, .destructive, .error, .success, .form, .list, .depositFlow, .amount, .amountFlow: self.kind = kind + } + } +} + +// MARK: - Catalog + +/// Every kind of tray the app needs, each built the one way the component offers; the Beta +/// Features showcase presents them over Settings. +public enum TrayKind: String, Identifiable, CaseIterable { + case info + case decision + case destructive + case error + case success + case form + case list + case depositFlow + case amount + case amountFlow + + public var id: String { rawValue } + + /// The kind's name in the sandbox menu. + public var name: String { + switch self { + case .info: "Info, one CTA" + case .decision: "Decision, no X" + case .destructive: "Destructive confirm" + case .error: "Error" + case .success: "Success, not dismissable" + case .form: "Text field, CTA states" + case .list: "List content" + case .depositFlow: "Multi-step flow" + case .amount: "Amount" + case .amountFlow: "Amount, multi-step" + } + } + + /// Whether the kind can be dragged or closed away. + public var isDismissable: Bool { + switch self { + case .decision, .success: false + case .info, .destructive, .error, .form, .list, .depositFlow, .amount, .amountFlow: true + } + } + + /// The kind's tray, for `.tray(item:)`. + @MainActor @ViewBuilder + public var tray: some View { + switch self { + case .info: InfoTray() + case .decision: DecisionTray() + case .destructive: DestructiveTray() + case .error: ErrorTray() + case .success: SuccessTray() + case .form: FormTray() + case .list: ListTray() + case .depositFlow: DepositFlowTray() + case .amount: AmountTray() + case .amountFlow: AmountFlowTray() + } + } +} + +/// An X, an SF Symbol and one action. +private struct InfoTray: View { + + @Environment(\.dismiss) private var dismiss + + var body: some View { + Tray( + title: "End-to-End Encrypted", + message: "Messages and media in this chat are end-to-end encrypted. Only you and the person you\u{2019}re chatting with can read them.", + icon: .central(.lock), + actions: [TrayAction("Got It") { dismiss() }] + ) + } +} + +/// No X and no drag to dismiss: one of two answers. +private struct DecisionTray: View { + + @Environment(\.dismiss) private var dismiss + + var body: some View { + Tray( + title: "Share Analytics", + message: "Help us make Flipcash better by sharing anonymous usage data. You can change this anytime in Settings.", + icon: .central(.circleInfo), + actions: [ + TrayAction("Don't Share", role: .secondary) { dismiss() }, + TrayAction("Share") { dismiss() }, + ] + ) + } +} + +/// The outcome in a red capsule beside Cancel. +private struct DestructiveTray: View { + + @Environment(\.dismiss) private var dismiss + + var body: some View { + Tray( + title: "Are You Sure You Want To Log Out?", + message: "You\u{2019}ll need your Access Key to log back in.", + icon: .destructive, + style: .error, + actions: [ + TrayAction("Cancel", role: .secondary) { dismiss() }, + TrayAction("Log Out", role: .destructive) { dismiss() }, + ] + ) + } +} + +/// The error style with a single action. +private struct ErrorTray: View { + + @Environment(\.dismiss) private var dismiss + + var body: some View { + Tray( + title: "Something Went Wrong", + message: "Please try again later.", + icon: .destructive, + style: .error, + actions: [TrayAction("OK") { dismiss() }] + ) + } +} + +/// The success style, presented not dismissable as `DialogItem.success` is. +private struct SuccessTray: View { + + @Environment(\.dismiss) private var dismiss + + var body: some View { + Tray( + title: "Report Sent", + message: "Thanks. We\u{2019}ll take a look.", + icon: .central(.circleCheck), + style: .success, + actions: [TrayAction("OK") { dismiss() }] + ) + } +} + +/// No icon; a text field over the keyboard, and the CTA through disabled, loading and success. +private struct FormTray: View { + + @State private var name = "" + @State private var state: ButtonState = .normal + @FocusState private var isFocused: Bool + + @Environment(\.dismiss) private var dismiss + + var body: some View { + Tray( + title: "What\u{2019}s Your Name?", + message: "This is how you\u{2019}ll appear to others.", + actions: [ + TrayAction("Save", state: state, isEnabled: !name.isEmpty) { save() }, + ] + ) { + InputContainer(highlighted: isFocused) { + TextField("Your Name", text: $name) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .focused($isFocused) + .submitLabel(.done) + .padding(.horizontal, 15) + } + } + .onAppear { isFocused = true } + } + + private func save() { + Task { + state = .loading + try? await Task.sleep(for: .seconds(1)) + state = .success + try? await Task.sleep(for: .milliseconds(500)) + dismiss() + } + } +} + +/// Selectable rows as custom content, under two actions. +private struct ListTray: View { + + private let options = ["1 Hour", "8 Hours", "1 Week", "Always"] + + @State private var selection = "8 Hours" + + @Environment(\.dismiss) private var dismiss + + var body: some View { + Tray( + title: "Mute Notifications", + message: "You\u{2019}ll still receive messages and see unread counts.", + icon: .central(.bellOff), + actions: [ + TrayAction("Cancel", role: .secondary) { dismiss() }, + TrayAction("Mute") { dismiss() }, + ] + ) { + VStack(spacing: TrayOptionRow.gap) { + ForEach(options, id: \.self) { option in + TrayOptionRow(option, icon: .clock, isSelected: option == selection) { + selection = option + } + } + } + } + } +} + +/// Three steps at three heights behind an asset icon: education, address, confirm. Exercises the +/// X as back and the CTA's title changing in place. +private struct DepositFlowTray: View { + + enum Step: Hashable { + case education + case address + case confirm + } + + @State private var flow = TrayFlow(root: Step.education) + /// Which way an autoplay step walks the flow; it turns at either end. + @State private var replaysForward = true + + @Environment(\.dismiss) private var dismiss + + var body: some View { + TrayStack(flow: flow, icon: .image(.asset(.buyUSDC)), actions: actions) { step in + switch step { + case .education: + TrayPage( + title: "Manually Deposit USDC", + message: "This will not purchase the amount you selected. Once your USDC is deposited, you can buy." + ) + case .address: + TrayPage( + title: "Your Deposit Address", + message: "Send only USDC on Solana to this address." + ) { + ImmutableField("7Hf3kQ2nWzR8vLpA9cXtE5uJ1mYbD4sGqN6oKiV2TjPw") + } + case .confirm: + TrayPage(title: "Confirm Deposit", message: "We'll let you know when it arrives.") + } + } + .task { + guard TraySandbox.autoplays else { return } + for _ in 0..<4 { + try? await Task.sleep(for: .seconds(1.5)) + replayStep() + } + try? await Task.sleep(for: .seconds(1.5)) + dismiss() + } + } + + private func actions(for step: Step) -> [TrayAction] { + switch step { + case .education: [TrayAction("Next") { flow.push(.address) }] + case .address: [TrayAction("Continue") { flow.push(.confirm) }] + case .confirm: [TrayAction("Confirm") { dismiss() }] + } + } + + private func replayStep() { + if replaysForward { + switch flow.current { + case .education: flow.push(.address) + case .address: flow.push(.confirm) + case .confirm: + replaysForward = false + flow.back() + } + } else { + flow.back() + if !flow.canGoBack { replaysForward = true } + } + } +} + +// MARK: - Amount + +/// The sandbox's balance, matching the backdrop's "$1,250.00 available". +private let sandboxBalance: Decimal = 1250 + +/// The parsed amount for `string`, through the one parser keypad amounts use. +private func sandboxAmount(_ string: String) -> Decimal? { + AmountValidator().validate(string) +} + +/// `amount` formatted for display; whole amounts drop their cents unless `showsCents`. +private func sandboxFormatted(_ amount: Decimal, showsCents: Bool = false) -> String { + NumberFormatter.fiat(currency: .usd, minimumFractionDigits: showsCents ? 2 : 0).string(from: amount as NSDecimalNumber) ?? "" +} + + +/// Whether `string` is an amount the CTA can go on with. +private func sandboxCanContinue(_ string: String) -> Bool { + guard let amount = sandboxAmount(string) else { return false } + return amount > 0 && amount <= sandboxBalance +} + +/// The keypad page, captioned with the balance, in the error colour once the amount passes it. +private struct SandboxAmountPage: View { + + @Binding var amount: String + + var body: some View { + let exceeds = (sandboxAmount(amount) ?? 0) > sandboxBalance + TrayPage { + TrayAmountEntry( + amount: $amount, + currency: .usd, + caption: "\(sandboxFormatted(sandboxBalance, showsCents: true)) available", + isCaptionError: exceeds + ) + } + } +} + +/// One step: the keypad, its title in the header beside the X. +private struct AmountTray: View { + + private enum Step: Hashable { + case only + } + + @State private var flow = TrayFlow(root: Step.only) + @State private var amount = "" + + @Environment(\.dismiss) private var dismiss + + var body: some View { + TrayStack( + flow: flow, + icon: { _ in nil }, + headerTitle: { _ in "Add Cash" }, + actions: { _ in [TrayAction("Continue", isEnabled: sandboxCanContinue(amount)) { dismiss() }] } + ) { _ in + SandboxAmountPage(amount: $amount) + } + } +} + +/// Presets in a grid, then a custom amount on the keypad, then a review: three heights in one +/// tray, every title in the header so only the line itself crossfades between steps, and Continue +/// holding its place until the last step. +private struct AmountFlowTray: View { + + enum Step: Hashable { + case choose + case custom + case review + } + + private static let presets = ["10", "25", "50", "100", "250"] + + @State private var flow = TrayFlow(root: Step.choose) + @State private var preset: String? + @State private var custom = "" + /// Whether the review shows the custom amount rather than a preset. + @State private var usesCustom = false + + @Environment(\.dismiss) private var dismiss + + var body: some View { + TrayStack(flow: flow, icon: { _ in nil }, headerTitle: headerTitle, actions: actions) { step in + switch step { + case .choose: + TrayPage(message: "Choose how much to add to your balance.") { + LazyVGrid(columns: TrayOptionTile.columns, spacing: TrayMetrics.rowGap) { + ForEach(Self.presets, id: \.self) { value in + TrayOptionTile(sandboxFormatted(Decimal(string: value) ?? 0), isSelected: preset == value) { + preset = value + } + } + TrayOptionTile("Custom") { + flow.push(.custom) + } + } + } + case .custom: + SandboxAmountPage(amount: $custom) + case .review: + // The amount where the keypad step drew it, so the step reads as the same number settling. + TrayPage { + VStack(alignment: .leading, spacing: TrayAmountMetrics.amountToCaption) { + Text(verbatim: sandboxFormatted(reviewAmount)) + .font(.default(size: TrayAmountMetrics.amountSize, weight: .bold)) + .foregroundStyle(Color.textMain) + .frame(height: TrayAmountMetrics.amountHeight) + Text("It\u{2019}ll be in your balance right away. You can withdraw it at any time.") + .font(.default(size: TrayAmountMetrics.captionSize)) + .foregroundStyle(Color.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } + + private var reviewAmount: Decimal { + sandboxAmount(usesCustom ? custom : preset ?? "") ?? 0 + } + + private func headerTitle(for step: Step) -> String? { + switch step { + case .choose: "Choose Amount" + case .custom: "Custom Amount" + case .review: "Review" + } + } + + private func actions(for step: Step) -> [TrayAction] { + switch step { + case .choose: + [TrayAction("Continue", isEnabled: preset != nil) { + usesCustom = false + flow.push(.review) + }] + case .custom: + [TrayAction("Continue", isEnabled: sandboxCanContinue(custom)) { + usesCustom = true + flow.push(.review) + }] + case .review: + [TrayAction("Add Cash") { dismiss() }] + } + } +} + +// MARK: - Cross check + +/// `CrossMediumShape` and the Central `crossMedium` asset at the tray X's tuned size, apart and +/// overlaid (shape red, asset green, yellow where both draw), and the overlay at 10× +/// (`--tray-sandbox-cross`). +private struct CrossComparison: View { + + var body: some View { + let box = TrayMetrics.closeGlyphSize / CrossMediumShape.extentRatio + VStack(spacing: 60) { + HStack(spacing: 24) { + shape.frame(width: box, height: box) + asset.frame(width: box, height: box) + overlay.frame(width: box, height: box) + } + overlay + .frame(width: box, height: box) + .scaleEffect(10) + .frame(width: box * 10, height: box * 10) + } + .foregroundStyle(Color.white) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + } + + private var overlay: some View { + ZStack { + shape.foregroundStyle(.red) + asset.foregroundStyle(.green).blendMode(.screen) + } + } + + private var shape: some View { + CrossMediumShape() + .stroke(style: StrokeStyle(lineWidth: TrayMetrics.closeStroke, lineCap: .round)) + } + + private var asset: some View { + Image.icon(.crossMedium).resizable() + } +} + +// MARK: - Backdrop + +/// A static Amount Entry look-alike for the trays to float over, built from plain text. +private struct AmountEntryBackdrop: View { + + let lastDismissed: String? + let open: (TrayKind) -> Void + + private let keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", ".", "0", "⌫"] + + var body: some View { + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 8) { + Text("$24.50") + .font(.appDisplayLarge) + .foregroundStyle(Color.textMain) + Text(lastDismissed.map { "\($0) dismissed" } ?? "$1,250.00 available") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + } + + Spacer() + + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 24) { + ForEach(keys, id: \.self) { key in + Text(key) + .font(.appKeyboard) + .foregroundStyle(Color.textMain.opacity(0.35)) + } + } + .padding(.bottom, 32) + .accessibilityHidden(true) + + Menu { + ForEach(TrayKind.allCases) { kind in + Button(kind.name) { open(kind) } + } + } label: { + Text("Open a Tray") + .frame(maxWidth: .infinity) + } + .buttonStyle(.filled20Compact) + .padding(.bottom, 8) + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.backgroundMain) + } +} + +// MARK: - Previews + +/// A tray's card over the app background, as it sits at the bottom of the screen. +private struct TrayPreviewCard: View { + + let kind: TrayKind + + var body: some View { + kind.tray + .environment(\.trayIsDismissable, kind.isDismissable) + .padding(8) + .frame(maxHeight: .infinity, alignment: .bottom) + .background(Color.backgroundMain) + .preferredColorScheme(.dark) + } +} + +#Preview("Info") { TrayPreviewCard(kind: .info) } +#Preview("Decision") { TrayPreviewCard(kind: .decision) } +#Preview("Destructive") { TrayPreviewCard(kind: .destructive) } +#Preview("Error") { TrayPreviewCard(kind: .error) } +#Preview("Success") { TrayPreviewCard(kind: .success) } +#Preview("Text field") { TrayPreviewCard(kind: .form) } +#Preview("List") { TrayPreviewCard(kind: .list) } +#Preview("Multi-step flow") { TrayPreviewCard(kind: .depositFlow) } + +#Preview("Flow, second step") { + let flow = TrayFlow(root: 0) + flow.push(1) + return TrayStack(flow: flow, icon: .image(.asset(.buyUSDC)), actions: { _ in [TrayAction("Confirm") {}] }) { _ in + TrayPage(title: "Confirm Deposit", message: "We'll let you know when it arrives.") + } + .padding(8) + .frame(maxHeight: .infinity, alignment: .bottom) + .background(Color.backgroundMain) +} + +#Preview("CTA states") { + VStack(spacing: 12) { + TrayActionBar(actions: [TrayAction("Disabled", isEnabled: false) {}]) + TrayActionBar(actions: [TrayAction("Loading", state: .loading) {}]) + TrayActionBar(actions: [TrayAction("Done", state: .success) {}]) + } + .padding(24) + .frame(maxHeight: .infinity) + .background(Color.backgroundMain) +} + +#Preview("Largest Dynamic Type") { + TrayPreviewCard(kind: .destructive) + .dynamicTypeSize(.accessibility3) +} + +#Preview("Tray sandbox") { + TraySandbox() +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TraySheetDriver.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TraySheetDriver.swift new file mode 100644 index 000000000..0c78e500c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TraySheetDriver.swift @@ -0,0 +1,246 @@ +// +// TraySheetDriver.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import UIKit + +/// The UIKit seam that keeps a tray's sheet invisible and at least as tall as the tray's card. +/// +/// The tray draws its own card in SwiftUI, so the card, its content and its actions resize on one +/// clock. The system sheet is only the invisible container that presents, drags and dims: the +/// driver reaches its `UISheetPresentationController`, gives it a custom detent that reads a +/// height the driver controls, and clears the detent's background effect so the system glass +/// never shows. The sheet grows at once when the card grows and shrinks once the card's resize has +/// settled, so its edge is never on screen. +/// +/// Measured (simulator, 3 runs each): a sheet resized on its own curve, on a UIKit spring or on a +/// SwiftUI animation bridged to UIKit leaves a CTA inside the content 6–18 pt off during resizes, +/// because Core Animation and SwiftUI start the two motions on different frames. A card drawn in +/// SwiftUI inside an invisible sheet holds the CTA within 0.02 pt. +@MainActor +final class TraySheetDriver { + + private static let detentIdentifier = UISheetPresentationController.Detent.Identifier("com.flipcash.tray") + + private weak var sheet: UISheetPresentationController? + private var cardHeight: CGFloat = 0 + private var dimsBackground = true + /// The bottom safe area the system gives the floating sheet's content, read once before the + /// driver cancels it, since it reads high while the cancellation lands. + private var systemBottomInset: CGFloat = 0 + + /// The height the detent shows: the card's, or the taller height a shrinking card is leaving. + private var shownHeight: CGFloat = 0 + private var pendingShrink: DispatchWorkItem? + + /// Told the tallest the card can be whenever the sheet's limit changes. + var onMaximumCardHeight: ((CGFloat) -> Void)? + private var maximumCardHeight: CGFloat = 0 + + /// Finds the sheet presenting `controller` and takes its detents over once the card has a + /// height. + func attach(from controller: UIViewController) { + guard sheet == nil, let found = Self.sheet(presenting: controller) else { return } + sheet = found + reclaimIfNeeded() + } + + /// Sizes the sheet for a card `cardHeight` tall: taller at once, shorter once the card's resize + /// spring has settled. + func update(cardHeight: CGFloat, dimsBackground: Bool) { + let restyled = dimsBackground != self.dimsBackground + let previousHeight = self.cardHeight + self.cardHeight = cardHeight + self.dimsBackground = dimsBackground + + guard let sheet, cardHeight > 0 else { return } + if !owns(sheet) || restyled { + takeOver(sheet) + sheet.invalidateDetents() + } + // Should SwiftUI configure the sheet again later in this pass and put its own detent back, + // the check after the pass takes it over again. + DispatchQueue.main.async { [weak self] in self?.reclaimIfNeeded() } + if cardHeight != previousHeight { + resize(to: cardHeight, from: previousHeight) + } + } + + /// Takes the sheet's detents back if something else replaced them. + func reclaimIfNeeded() { + guard let sheet, cardHeight > 0, !owns(sheet) else { return } + if shownHeight == 0 { shownHeight = cardHeight } + takeOver(sheet) + sheet.invalidateDetents() + } + + // MARK: - Resizing + + /// Grows the sheet in this pass and shrinks it once the card's spring has settled. The sheet's + /// edge never animates: it is invisible, and the card moves on its own spring inside it. + private func resize(to height: CGFloat, from previousHeight: CGFloat) { + pendingShrink?.cancel() + pendingShrink = nil + let isOnScreen = sheet?.presentedViewController.viewIfLoaded?.window != nil + guard isOnScreen, previousHeight > 0 else { + show(height) + return + } + let spring = TrayMotion.resize.spring + // A bouncy resize carries a growing card past its new height, and the sheet clips the card. + let growth = max(0, height - previousHeight) + let overshoot = growth > 0 && spring.bounce > 0 ? growth * Self.overshoot(of: spring) : 0 + let tallest = height + overshoot.rounded(.up) + if tallest > shownHeight { + show(tallest) + } + guard shownHeight > height else { return } + let shrink = DispatchWorkItem { [weak self] in self?.show(height) } + pendingShrink = shrink + DispatchQueue.main.asyncAfter(deadline: .now() + spring.settlingDuration, execute: shrink) + } + + /// How far past its target `spring` carries a move, as a share of the move. + private static func overshoot(of spring: Spring) -> CGFloat { + let duration = spring.settlingDuration + let peak = (1...60).reduce(1.0) { peak, sample in + max(peak, spring.value(target: 1.0, time: duration * Double(sample) / 60)) + } + return peak - 1 + } + + private func show(_ height: CGFloat) { + guard let sheet, height != shownHeight else { return } + shownHeight = height + sheet.invalidateDetents() + sheet.containerView?.layoutIfNeeded() + } + + // MARK: - Detent + + private func takeOver(_ sheet: UISheetPresentationController) { + let detent = UISheetPresentationController.Detent.custom(identifier: Self.detentIdentifier) { [weak self] context in + MainActor.assumeIsolated { self?.detentHeight(in: context) } + } + // A translucent `.presentationBackground`, clear included, leaves the floating sheet's + // Liquid Glass in place, and the sheet's own `backgroundEffect` is reset when SwiftUI + // configures it; the detent's holds. + if #available(iOS 26.1, *) { + detent.backgroundEffect = UIColorEffect(color: .clear) + } + sheet.detents = [detent] + sheet.selectedDetentIdentifier = Self.detentIdentifier + sheet.largestUndimmedDetentIdentifier = dimsBackground ? nil : Self.detentIdentifier + sheet.prefersGrabberVisible = false + cancelBottomInset(sheet) + } + + private func owns(_ sheet: UISheetPresentationController) -> Bool { + sheet.detents.count == 1 && sheet.detents.first?.identifier == Self.detentIdentifier + } + + /// A detent leaves out the sheet's bottom safe area, which the card covers on iOS 26; on + /// iOS 18 the card's content stays above the home indicator, so the detent is the card's height. + private func detentHeight(in context: UISheetPresentationControllerDetentResolutionContext) -> CGFloat? { + reportMaximumCardHeight(context.maximumDetentValue + systemBottomInset) + let height = shownHeight > 0 ? shownHeight : cardHeight + guard height > 0 else { return nil } + return min(height - systemBottomInset, context.maximumDetentValue) + } + + /// Passes on the tallest card the detent can show, after the layout pass that resolved it. + private func reportMaximumCardHeight(_ height: CGFloat) { + guard height > 0, height != maximumCardHeight else { return } + maximumCardHeight = height + DispatchQueue.main.async { [weak self] in self?.onMaximumCardHeight?(height) } + } + + /// Cancels the floating sheet's bottom safe area, which nothing covers, so the card's bottom + /// edge sits on the sheet's. + private func cancelBottomInset(_ sheet: UISheetPresentationController) { + guard #available(iOS 26, *) else { return } + let controller = sheet.presentedViewController + guard controller.isViewLoaded else { return } + if controller.additionalSafeAreaInsets.bottom == 0 { + systemBottomInset = controller.view.safeAreaInsets.bottom + } + controller.additionalSafeAreaInsets.bottom = -systemBottomInset + } + + // MARK: - Lookup + + /// The sheet of the presented controller that contains `controller`. Only a controller that is + /// itself presented counts: `sheetPresentationController` on any other creates a new, unused + /// sheet on read. + private static func sheet(presenting controller: UIViewController) -> UISheetPresentationController? { + var candidate: UIViewController? = controller + while let current = candidate { + if isPresented(current) { + return current.sheetPresentationController + } + candidate = current.parent + } + var responder: UIResponder? = controller.viewIfLoaded + while let next = responder?.next { + if let owner = next as? UIViewController, isPresented(owner) { + return owner.sheetPresentationController + } + responder = next + } + return nil + } + + private static func isPresented(_ controller: UIViewController) -> Bool { + controller.presentingViewController?.presentedViewController === controller + } +} + +/// Hosts a ``TraySheetDriver`` inside the sheet's content, where it can reach the sheet. +struct TraySheetSeam: UIViewControllerRepresentable { + + let cardHeight: CGFloat + let dimsBackground: Bool + /// Told the tallest the card can be in this sheet. + let onMaximumCardHeight: (CGFloat) -> Void + + func makeUIViewController(context: Context) -> TraySheetSeamController { + TraySheetSeamController() + } + + func updateUIViewController(_ controller: TraySheetSeamController, context: Context) { + controller.driver.onMaximumCardHeight = onMaximumCardHeight + controller.driver.attach(from: controller) + controller.driver.update(cardHeight: cardHeight, dimsBackground: dimsBackground) + } +} + +/// An invisible controller that attaches its driver to the sheet once it joins the hierarchy. +final class TraySheetSeamController: UIViewController { + + let driver = TraySheetDriver() + + override func loadView() { + view = UIView() + view.isUserInteractionEnabled = false + } + + override func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + driver.attach(from: self) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + driver.attach(from: self) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + // SwiftUI can configure the sheet once more as the presentation lands. + driver.reclaimIfNeeded() + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayStack.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayStack.swift new file mode 100644 index 000000000..3ecf949f6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayStack.swift @@ -0,0 +1,268 @@ +// +// TrayStack.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI + +/// A multi-step tray: the card around the page for its flow's current step, with the icon, the +/// X and the actions. +/// +/// Only the page swaps between steps, blur-crossfading in place, while the card resizes on one +/// spring with its content and actions, so the actions hold still on screen. The X goes back a +/// step from the second step on and dismisses on the root, where it is hidden for a tray +/// presented with `isDismissable: false`. +public struct TrayStack: View { + + private let flow: TrayFlow + private let icon: (Step) -> TrayIcon? + private let headerTitle: (Step) -> String? + /// Whether the icon was given per step; one icon for the whole flow never swaps. + private var iconVaries = true + private let style: TrayStyle + private let actions: (Step) -> [TrayAction] + private let page: (Step) -> Page + + @State private var title: String? + + @Environment(\.dismiss) private var dismiss + @Environment(\.trayIsDismissable) private var isDismissable + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + /// Creates a tray for `flow`, with `actions` and a `page` (a ``TrayPage``) for each step. + public init( + flow: TrayFlow, + icon: TrayIcon? = nil, + style: TrayStyle = .standard, + actions: @escaping (Step) -> [TrayAction], + @ViewBuilder page: @escaping (Step) -> Page + ) { + self.init(flow: flow, icon: { _ in icon }, headerTitle: { _ in nil }, style: style, actions: actions, page: page) + iconVaries = false + } + + /// Creates a tray whose icon can change or drop away per step, such as a keypad step that + /// needs the height; a change of icon crossfades with the page, and no icon closes its space. + /// A step with no icon and a `headerTitle` shows that title in the icon's corner, level with + /// the X, over a page made with ``TrayPage/init(content:)``. + public init( + flow: TrayFlow, + icon: @escaping (Step) -> TrayIcon?, + headerTitle: @escaping (Step) -> String? = { _ in nil }, + style: TrayStyle = .standard, + actions: @escaping (Step) -> [TrayAction], + @ViewBuilder page: @escaping (Step) -> Page + ) { + self.flow = flow + self.icon = icon + self.headerTitle = headerTitle + self.style = style + self.actions = actions + self.page = page + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + + // A page taller than the screen allows, as at accessibility sizes, scrolls between the + // header and the actions. + ViewThatFits(in: .vertical) { + pageRegion + ScrollView { pageRegion } + .scrollBounceBehavior(.basedOnSize) + } + .padding(.top, TrayMetrics.iconToTitle) + // VoiceOver reads the title first, then the actions, then the X. + .accessibilitySortPriority(3) + + // A tray whose content is its own set of choices has no bar. + if !actions(flow.current).isEmpty { + TrayActionBar(actions: actions(flow.current), movesForward: flow.direction == .forward) + .padding(.top, TrayMetrics.contentToActions) + .accessibilitySortPriority(2) + } + } + .padding(TrayMetrics.padding) + // Inside the resize animation with the content: outside it, the surface snaps to the new + // height while the content springs. + .background { TrayFill(style: style) } + .animation(TrayMotion.resize.animation, value: flow.path) + .onPreferenceChange(TrayTitleKey.self) { title = $0 } + // UI tests find a dialog by its title, and the tray stands in for dialogs. + .accessibilityElement(children: .contain) + .accessibilityIdentifier(title ?? headerTitle(flow.current) ?? "Tray") + } + + private var pageRegion: some View { + ZStack(alignment: .topLeading) { + page(flow.current) + .id(flow.current) + .transition(pageTransition) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + // Pages fading in and out move with the region as the tray resizes, not on their own. + .geometryGroup() + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .top, spacing: 0) { + ZStack(alignment: .topLeading) { + if let icon = icon(flow.current) { + TrayIconView(icon: icon, color: style.iconColor) + // The same icon on the next step stays put; only a change swaps it. + .id(iconVaries ? icon.identity ?? AnyHashable(flow.current) : nil) + .transition(pageTransition) + } else if let title = headerTitle(flow.current) { + Text(title) + // SF Pro until type tokens are decided (Phase 2). + .font(.system(size: TrayMetrics.scaled(TrayMetrics.headerTitleSize, .headline, dynamicTypeSize), weight: TrayMetrics.titleWeight)) + .tracking(TrayMetrics.titleTracking) + .foregroundStyle(Color.textMain) + .lineLimit(1) + .frame(minHeight: TrayMetrics.closeSize, alignment: .leading) + .accessibilityAddTraits(.isHeader) + // The same title on the next step stays put; a new one crossfades in place. + .id("title:" + title) + .transition(headerTitleTransition) + } + } + // An icon or title fading in or out rides the card's moving top edge with the rest of + // the header, as the pages do, instead of standing at its own ends of the resize. + .geometryGroup() + Spacer(minLength: 0) + closeButton + } + } + + private var closeButton: some View { + let isVisible = flow.canGoBack || isDismissable + return TrayCloseButton(accessibilityLabel: flow.canGoBack ? "Back" : "Close") { + flow.close { dismiss() } + } + .opacity(isVisible ? 1 : 0) + .allowsHitTesting(isVisible) + .accessibilityHidden(!isVisible) + .accessibilitySortPriority(1) + } + + // MARK: - Transitions + + /// A header title swaps by fading alone, on the page's timing, so it reads as the same line. + private var headerTitleTransition: AnyTransition { + .trayFade( + insertion: TrayFade(opacity: 0, blur: 0, scale: 1), + insertionAnimation: TrayMotion.contentIn.animation.delay(TrayMotion.contentInDelay), + removal: TrayFade(opacity: 0, blur: 0, scale: 1), + removalAnimation: TrayMotion.contentOut.animation + ) + } + + // Reduce Motion keeps the fade and drops the blur and scale. + private var pageTransition: AnyTransition { + let blur = reduceMotion ? 0 : TrayMotion.pageBlur + let scales = TrayMotion.pageScales && !reduceMotion + return .trayFade( + insertion: TrayFade(opacity: 0, blur: blur, scale: scales ? TrayMotion.pageScaleIn : 1), + insertionAnimation: TrayMotion.contentIn.animation.delay(TrayMotion.contentInDelay), + removal: TrayFade(opacity: 0, blur: blur, scale: scales ? TrayMotion.pageScaleOut : 1, collapses: true), + removalAnimation: TrayMotion.contentOut.animation + ) + } +} + +/// A single-step tray: an optional icon, the X, a title and message, optional content, and one +/// or two actions. +public struct Tray: View { + + private let title: String + private let message: String? + private let icon: TrayIcon? + private let style: TrayStyle + private let actions: [TrayAction] + private let content: Content? + + @State private var flow = TrayFlow(root: SingleStep.only) + + /// Creates a tray with `content` under the title and message, localizing literal copy. + public init( + title: LocalizedStringResource, + message: LocalizedStringResource? = nil, + icon: TrayIcon? = nil, + style: TrayStyle = .standard, + actions: [TrayAction], + @ViewBuilder content: () -> Content + ) { + self.init(title: String(localized: title), message: message.map { String(localized: $0) }, icon: icon, style: style, actions: actions, content: content()) + } + + /// Creates a tray with `content` under a title and message that are already localized. + @_disfavoredOverload + public init( + title: String, + message: String? = nil, + icon: TrayIcon? = nil, + style: TrayStyle = .standard, + actions: [TrayAction], + @ViewBuilder content: () -> Content + ) { + self.init(title: title, message: message, icon: icon, style: style, actions: actions, content: content()) + } + + /// Creates a tray whose content may be absent. + private init(title: String, message: String?, icon: TrayIcon?, style: TrayStyle, actions: [TrayAction], content: Content?) { + self.title = title + self.message = message + self.icon = icon + self.style = style + self.actions = actions + self.content = content + } + + public var body: some View { + TrayStack(flow: flow, icon: { _ in icon }, style: style, actions: { _ in actions }) { _ in + TrayPage(title: title, message: message, content: content) + } + } + + private enum SingleStep: Hashable { + case only + } +} + +extension Tray where Content == EmptyView { + + /// Creates a tray of a title, an optional message and its actions, localizing literal copy. + public init( + title: LocalizedStringResource, + message: LocalizedStringResource? = nil, + icon: TrayIcon? = nil, + style: TrayStyle = .standard, + actions: [TrayAction] + ) { + self.init(title: String(localized: title), message: message.map { String(localized: $0) }, icon: icon, style: style, actions: actions, content: nil) + } + + /// Creates a tray of a title and an optional message that are already localized, and its + /// actions. + @_disfavoredOverload + public init( + title: String, + message: String? = nil, + icon: TrayIcon? = nil, + style: TrayStyle = .standard, + actions: [TrayAction] + ) { + self.init(title: title, message: message, icon: icon, style: style, actions: actions, content: nil) + } +} + +extension EnvironmentValues { + /// Whether the presented tray can be dismissed; set by `.tray(…)`. + @Entry var trayIsDismissable = true +} diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayStyle.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayStyle.swift new file mode 100644 index 000000000..38db631b9 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayStyle.swift @@ -0,0 +1,40 @@ +// +// TrayStyle.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI + +/// The tone of a tray: a standard one, or one reporting a success or an error. +/// +/// Success draws as standard. Error darkens the card under red fading down from its top edge and +/// draws the icon red. +public enum TrayStyle: Hashable, Sendable { + /// Information, a decision or a confirmation. + case standard + /// Something finished as hoped. + case success + /// Something failed, or a warning before an action that can't be undone. + case error +} + +extension TrayStyle { + + /// Whether the card darkens under red fading down from its top edge. + var fadesRed: Bool { + switch self { + case .standard, .success: false + case .error: true + } + } + + /// The icon's colour. + var iconColor: Color { + switch self { + case .standard, .success: .textSecondary + case .error: .textError + } + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Views/AmountText.swift b/FlipcashUI/Sources/FlipcashUI/Views/AmountText.swift index 5cfd326be..59bc6aeb2 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/AmountText.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/AmountText.swift @@ -36,8 +36,8 @@ public struct AmountText: View { ) if showChevron { - Image.system(.chevronDown) - .font(.default(size: 12, weight: .bold)) + Image.icon(.chevronDownMedium) + .iconSize(16) } } Text(content) diff --git a/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/IconButtonStyle.swift b/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/IconButtonStyle.swift index 32d65a3da..c4c2904f2 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/IconButtonStyle.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/IconButtonStyle.swift @@ -17,15 +17,20 @@ import SwiftUI /// .buttonStyle(.icon(.web)) /// ``` public struct IconButtonStyle: ButtonStyle { - public let icon: Asset + public let icon: Image public init(icon: Asset) { - self.icon = icon + self.icon = Image.asset(icon) + } + + /// Creates the style with a Central icon. + public init(icon: Icon) { + self.icon = Image.icon(icon) } public func makeBody(configuration: Configuration) -> some View { HStack(alignment: .center, spacing: 10) { - Image.asset(icon) + icon .resizable() .aspectRatio(contentMode: .fit) .frame(maxWidth: 18, maxHeight: 18) @@ -49,4 +54,10 @@ extension ButtonStyle where Self == IconButtonStyle { public static func icon(_ icon: Asset) -> IconButtonStyle { IconButtonStyle(icon: icon) } + + /// A button style that displays a leading Central icon alongside the label + /// inside a translucent rounded container. + public static func icon(_ icon: Icon) -> IconButtonStyle { + IconButtonStyle(icon: icon) + } } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift b/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift index 4ab272c6d..7b3338e1c 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift @@ -73,6 +73,7 @@ public struct ContactAvatarView: View { } .frame(width: size, height: size) .clipShape(clipShape) + .overlay(Circle().strokeBorder(Color.white.opacity(ChatStyle.avatarStrokeOpacity), lineWidth: ChatStyle.avatarStrokeWidth)) .accessibilityElement(children: .ignore) .accessibilityLabel(Text(displayName.isEmpty ? "Contact" : displayName)) .accessibilityAddTraits(.isImage) diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/ListRow.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/ListRow.swift new file mode 100644 index 000000000..64b3e20a1 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/ListRow.swift @@ -0,0 +1,241 @@ +// +// ListRow.swift +// FlipcashUI +// + +import SwiftUI + +/// A tappable list row: a fixed leading slot, a title with an optional subtitle, and a trailing +/// accessory, with a divider that starts at the text and stops at the trailing margin. +public struct ListRow: View { + + private let title: Text + private let subtitle: Text? + private let tone: ListRowTone + private let showsDivider: Bool + private let leading: Leading + private let trailing: Trailing + private let action: () -> Void + + @Environment(\.isEnabled) private var isEnabled + @Environment(\.displayScale) private var displayScale + + /// Creates a row with localized copy; pass `EmptyView()` content for a slot the row doesn't use. + public init( + _ title: LocalizedStringKey, + subtitle: LocalizedStringKey? = nil, + tone: ListRowTone = .standard, + showsDivider: Bool = true, + action: @escaping () -> Void, + @ViewBuilder leading: () -> Leading, + @ViewBuilder trailing: () -> Trailing + ) { + self.title = Text(title) + self.subtitle = subtitle.map { Text($0) } + self.tone = tone + self.showsDivider = showsDivider + self.leading = leading() + self.trailing = trailing() + self.action = action + } + + /// Creates a row that shows `title` and `subtitle` verbatim, for copy that's already localized + /// or is data, like a region's name. + @_disfavoredOverload + public init( + _ title: S, + subtitle: S? = nil, + tone: ListRowTone = .standard, + showsDivider: Bool = true, + action: @escaping () -> Void, + @ViewBuilder leading: () -> Leading, + @ViewBuilder trailing: () -> Trailing + ) { + self.title = Text(title) + self.subtitle = subtitle.map { Text($0) } + self.tone = tone + self.showsDivider = showsDivider + self.leading = leading() + self.trailing = trailing() + self.action = action + } + + public var body: some View { + Button(action: action) { + HStack(spacing: ListRowMetrics.gap) { + if Leading.self != EmptyView.self { + leading + .foregroundStyle(titleColor) + .environment(\.listRowTone, tone) + .frame(width: ListRowMetrics.leadingSlot, height: ListRowMetrics.leadingSlot) + } + HStack(spacing: ListRowMetrics.gap) { + VStack(alignment: .leading, spacing: ListRowMetrics.subtitleGap) { + title + .foregroundStyle(titleColor) + .font(.default(size: 16, weight: .medium)) + if let subtitle { + subtitle + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + trailing + } + .padding(.vertical, ListRowMetrics.verticalPadding) + .padding(.trailing, ListRowMetrics.margin) + .frame(minHeight: ListRowMetrics.minHeight) + .overlay(alignment: .bottom) { + if showsDivider { + Rectangle() + .fill(Color.rowSeparator) + .frame(height: 1 / displayScale) + .padding(.trailing, ListRowMetrics.dividerTrailingInset) + } + } + } + .padding(.leading, ListRowMetrics.margin) + .contentShape(Rectangle()) + } + .buttonStyle(ListRowButtonStyle()) + .opacity(isEnabled ? 1 : 0.4) + } + + private var titleColor: Color { + switch tone { + case .standard: .textMain + case .destructive: .textError + } + } +} + +/// The colour treatment of a row's title and leading icon. +public enum ListRowTone { + case standard + case destructive +} + +/// The row's metrics, shared by `ListRow` and `ListSectionTitle` so a group of rows stays aligned. +public enum ListRowMetrics { + /// Screen-edge inset on both sides. + public static let margin: CGFloat = 20 + /// The smallest a single-line row can be. + public static let minHeight: CGFloat = 60 + /// Space above and below the text. + public static let verticalPadding: CGFloat = 20 + /// Width and height of the leading slot; the title starts after it. + public static let leadingSlot: CGFloat = 30 + /// Space between the leading slot, the text and the trailing accessory. + public static let gap: CGFloat = 12 + /// Space between the title and the subtitle. + public static let subtitleGap: CGFloat = 2 + /// How far before the trailing edge the divider stops. + public static let dividerTrailingInset: CGFloat = 20 + /// The size of an icon in the leading slot. + public static let iconSize: CGFloat = 20 + /// The opacity of an icon in the leading slot. + public static let iconOpacity: Double = 0.5 + /// The diameter of a flag in the leading slot. + public static let flagSize: CGFloat = 28 + /// Space above a section title. + public static let sectionTop: CGFloat = 24 + /// Space between a section title and its first row. + public static let sectionBottom: CGFloat = 0 + /// The opacity of a section title. + public static let sectionOpacity: Double = 0.5 +} + +/// A leading-slot icon at the row's icon size and opacity. +public struct ListRowIcon: View { + private let image: Image + + @Environment(\.listRowTone) private var tone + + /// Creates the icon from an image; it's drawn as a template in the row's title colour. + public init(_ image: Image) { + self.image = image + } + + public var body: some View { + image + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: ListRowMetrics.iconSize, height: ListRowMetrics.iconSize) + // A destructive row's icon stays at full strength so the red reads as a warning. + .opacity(tone == .destructive ? 1 : ListRowMetrics.iconOpacity) + } +} + +extension EnvironmentValues { + /// The tone of the row an accessory sits in. + @Entry var listRowTone: ListRowTone = .standard +} + +/// The trailing chevron of a row that pushes somewhere. +public struct ListRowChevron: View { + /// Creates the chevron. + public init() {} + + public var body: some View { + Image.icon(.chevronRightMedium) + .iconSize(16) + .foregroundStyle(Color.textSecondary.opacity(0.6)) + } +} + +/// The title above a group of rows. +public struct ListSectionTitle: View { + private let title: LocalizedStringKey + + /// Creates a section title from localized copy. + public init(_ title: LocalizedStringKey) { + self.title = title + } + + public var body: some View { + Text(title) + .font(.appTextSmall) + .foregroundStyle(Color.textMain.opacity(ListRowMetrics.sectionOpacity)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, ListRowMetrics.margin) + .padding(.top, ListRowMetrics.sectionTop) + .padding(.bottom, ListRowMetrics.sectionBottom) + .accessibilityAddTraits(.isHeader) + } +} + +/// Dims a row while it's pressed. +private struct ListRowButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .opacity(configuration.isPressed ? 0.6 : 1) + } +} + +#Preview { + Background(color: .backgroundMain) { + VStack(spacing: 0) { + ListSectionTitle("Security") + ListRow("Access Key", action: {}) { + ListRowIcon(Image.icon(.key1)) + } trailing: { + ListRowChevron() + } + ListRow("Blocked", action: {}) { + ListRowIcon(Image.icon(.circleBanSign)) + } trailing: { + Text("2").foregroundStyle(Color.textSecondary) + } + ListRow("Delete Account", tone: .destructive, showsDivider: false, action: {}) { + ListRowIcon(Image.icon(.trashCan)) + } trailing: { + EmptyView() + } + Spacer() + } + .font(.appTextMedium) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift index 5ad174ec6..b35895c78 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift @@ -9,9 +9,18 @@ import SwiftUI import FlipcashCore +/// Thrown by a ``SwipeControl`` action when the send went out and didn't go +/// through (a network error, say), so the control plays its failure look before +/// it resets. Any other error resets it silently. +public struct SwipeControlFailure: Error { + public init() {} +} + /// A slide-to-confirm control. The user drags the knob along the track; once it /// passes the commit threshold the `action` runs, showing a loading indicator -/// and then a success checkmark before the knob resets. +/// and then a success checkmark before the knob resets. An action that throws +/// ``SwipeControlFailure`` shows a brief failure before the reset; any other +/// error resets it silently. /// /// The knob commits when dragged past 70% of the track, or past 50% with a fast /// release velocity. @@ -23,16 +32,19 @@ import FlipcashCore /// ``` public struct SwipeControl: View { - @Environment(\.displayScale) private var displayScale @Environment(\.isEnabled) private var isEnabled + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var knobX: CGFloat = 0 @State private var state: KnobState = .normal + @State private var shakePhase: Double = 0 private let text: String private let action: ThrowingAction private let completion: ThrowingAction? + /// Creates the control with `text` on its track, which callers pass already localized; `action` + /// runs on commit and `completion` after the success check shows. public init(text: String, action: @escaping ThrowingAction, completion: ThrowingAction? = nil) { self.text = text self.action = action @@ -41,37 +53,42 @@ public struct SwipeControl: View { public var body: some View { GeometryReader { geometry in - let maxX = geometry.size.width - Layout.knobSize.width + let diameter = geometry.size.height + // Never narrower than tall, so the resting thumb always covers the track's leading end. + let thumbWidth = min(max(SwipeMetrics.thumbWidth, diameter), geometry.size.width) + let maxX = geometry.size.width - thumbWidth - SwipingTrack(text: text, maxX: maxX, knobX: knobX, isNormal: state == .normal) { + SwipingTrack( + text: text, + diameter: diameter, + thumbWidth: thumbWidth, + maxX: maxX, + knobX: knobX, + state: state + ) { + // Parks the thumb in the release's own update, so it never springs back first. + knobX = maxX Task { await commit(maxX: maxX) } } } - .padding(.horizontal, 4) - .frame(height: Layout.height) - .background(.action.opacity(0.1), in: RoundedRectangle(cornerRadius: Metrics.buttonRadius)) - .compositingGroup() - .clipShape(.rect(cornerRadius: Metrics.buttonRadius)) - .overlay { - ZStack { - RoundedRectangle(cornerRadius: Metrics.buttonRadius) - .stroke(Metrics.inputFieldStrokeColor(highlighted: false), lineWidth: 1 / displayScale) - - switch state { - case .normal: - EmptyView() - case .loading: - LoadingView(color: .gray, style: .medium) - case .success: - Image.asset(.checkmark) - .renderingMode(.template) - .foregroundStyle(.textMain) + .padding(SwipeMetrics.thumbInset) + .frame(height: SwipeMetrics.height) + .background { + Capsule() + .fill(trackFill) + .animation(.easeOut(duration: SwipeMetrics.failureTintFade), value: state == .failure) + } + .modifier(SwipeShake(phase: shakePhase, distance: SwipeMetrics.shakeDistance, count: SwipeMetrics.shakeCount)) + .opacity(isEnabled ? 1 : SwipeMetrics.disabledOpacity) + .sensoryFeedback(.impact(weight: .heavy), trigger: state) { _, newState in newState == .loading } + .sensoryFeedback(.error, trigger: state) { _, newState in newState == .failure } + .onChange(of: state) { _, newState in + if newState == .failure && !reduceMotion { + withAnimation(.linear(duration: SwipeMetrics.shakeDuration)) { + shakePhase += 1 } } - .animation(nil, value: state) } - .opacity(isEnabled ? 1 : Layout.disabledOpacity) - .sensoryFeedback(.impact(weight: .heavy), trigger: state) { _, newState in newState == .loading } .accessibilityRepresentation { Button(text) { Task { @@ -84,11 +101,30 @@ public struct SwipeControl: View { } } + private var trackFill: Color { + switch state { + case .failure: + .bannerError.opacity(SwipeMetrics.failureTint) + case .normal, .loading, .success: + SwipeMetrics.trackFill + } + } + private func commit(maxX: CGFloat) async { state = .loading - knobX = maxX + Layout.knobSize.width * 2 + knobX = maxX do { try await action() + } catch { + if error is SwipeControlFailure { + state = .failure + try? await Task.delay(milliseconds: Int(SwipeMetrics.failureHold * 1000)) + } + state = .normal + knobX = 0 + return + } + do { state = .success try await completion?() try await Task.delay(seconds: 2) @@ -96,78 +132,138 @@ public struct SwipeControl: View { state = .normal knobX = 0 } + + /// Whether a release at `percent` of the track, with the predicted end + /// `velocity` points ahead of the finger, commits. + nonisolated static func shouldCommit(percent: CGFloat, velocity: CGFloat) -> Bool { + percent > Layout.commitFraction + || (percent > Layout.velocityAssistFraction && velocity > Layout.velocityAssistMinimum) + } } /// Owns the high-frequency drag and idle-nudge state, and renders the label and -/// knob together so a drag (or nudge) re-renders only the track, not the -/// control's background, border, or status overlay. As the knob advances it -/// masks the label away from the left and fades the still-visible remainder -/// toward transparent. +/// thumb together so a drag (or nudge) re-renders only the track, not the +/// control's background or failure tint. As the thumb advances the label +/// fades toward transparent, bent by the lens wherever the thumb sits. private struct SwipingTrack: View { let text: String + let diameter: CGFloat + let thumbWidth: CGFloat let maxX: CGFloat let knobX: CGFloat - let isNormal: Bool + let state: KnobState let onCommit: () -> Void @Environment(\.isEnabled) private var isEnabled + @Environment(\.accessibilityReduceMotion) private var reduceMotion @GestureState private var dragOffset: CGFloat? = nil @State private var isNudging = false + @State private var isPressed = false + // Set on the press spring, outside the drag's animation-disabled transaction. + @State private var isLifted = false + // Bumped by a release that doesn't commit; the drag's end alone decides, whatever order the + // press detector's end arrives in. + @State private var releaseCount = 0 + @Namespace private var glassNamespace var body: some View { + let isNormal = state == .normal + let isTouching = dragOffset != nil + let wantsLift = (isPressed || isTouching) && isNormal && isEnabled let position = knobX + (dragOffset ?? 0) - let leadingX = position + Layout.knobSize.width + let nudgeX: CGFloat = (isNormal && isEnabled && dragOffset == nil && isNudging && !reduceMotion) ? SwipeMetrics.nudgeDistance : 0 + let snap = SwipeMetrics.snapBack.animation(reduceMotion: reduceMotion) + let nudge = SwipeMetrics.nudge.animation(reduceMotion: reduceMotion) + let press = SwipeMetrics.press.animation(reduceMotion: reduceMotion) + let labelOpacity = maxX > 0 ? 1 - min(max(position / maxX, 0), 1) : 1 - let nudgeX: CGFloat = (isNormal && isEnabled && dragOffset == nil && isNudging) ? Layout.nudgeOffset : 0 + let liftScale = isLifted && !reduceMotion ? SwipeMetrics.pressScale : 1 ZStack(alignment: .leading) { Text(text) .lineLimit(1) + .minimumScaleFactor(SwipeMetrics.labelMinimumScale) .font(.appTextMedium) .foregroundStyle(.textMain) - .padding(.horizontal, Layout.knobSize.width) - .frame(maxWidth: .infinity) // span the track so the mask offset below is in track coordinates - .mask(alignment: .leading) { - Color.black - .opacity(labelOpacity) - .offset(x: leadingX) - .animation(.springFastestDamped, value: leadingX) - } + // Before the lens, so the band bends with the label. + .modifier(SwipeShimmer( + isOn: !reduceMotion, + isRunning: isNormal && isEnabled && !isTouching + )) + .padding(.horizontal, thumbWidth) + .frame(maxWidth: .infinity, maxHeight: .infinity) // span the track so the mask and lens are in track coordinates + .modifier(SwipeLensEffect( + centerX: position + nudgeX + thumbWidth / 2, + radius: diameter / 2 * liftScale, + halfLength: (thumbWidth - diameter) / 2 * liftScale, + bend: SwipeMetrics.lensBend, + band: SwipeMetrics.lensBand, + zoom: SwipeMetrics.lensZoom, + falloff: SwipeMetrics.lensFalloff, + aberration: SwipeMetrics.lensAberration, + isEnabled: SwipeMetrics.usesGlass + )) + .animation(snap, value: position) + .animation(nudge, value: nudgeX) + .animation(press, value: isLifted) + .opacity(labelOpacity) .opacity(isNormal ? 1 : 0) - RoundedRectangle(cornerRadius: Layout.knobCornerRadius) - .fill(.action) - .frame(width: Layout.knobSize.width, height: Layout.knobSize.height) - .overlay { - Image.system(.arrowRight) - .resizable() - .scaledToFit() - .frame(width: Layout.arrowSize, height: Layout.arrowSize) - .foregroundStyle(.textAction) - } - .offset(x: position + nudgeX) - .animation(.springFastestDamped, value: position + nudgeX) - .gesture( - DragGesture(minimumDistance: 0) - .updating($dragOffset) { value, dragState, transaction in - transaction.disablesAnimations = true - dragState = min(max(value.translation.width, 0), maxX) - } - .onEnded { value in - let offset = min(max(value.translation.width, 0), maxX) - let velocity = value.predictedEndLocation.x - value.location.x - let percent = maxX > 0 ? offset / maxX : 0 - guard percent > Layout.commitFraction - || (percent > Layout.velocityAssistFraction && velocity > Layout.velocityAssistMinimum) - else { return } - onCommit() + SwipeThumbSurface(isIdle: isNormal, glassNamespace: glassNamespace) { + thumbIcon + } + .animation(press) { $0.scaleEffect(liftScale) } + .opacity(isLifted && reduceMotion ? SwipeMetrics.pressDim : 1) + .frame(width: thumbWidth, height: diameter) + .contentShape(.capsule) // the lens's see-through middle takes the touch too + .offset(x: position) + .animation(snap, value: position) + .offset(x: nudgeX) + .animation(nudge, value: nudgeX) + // Sees touch-down even where a sheet holds the drag back until the finger moves. + .gesture(SwipePressDetector { isPressed = $0 }) + .gesture( + DragGesture(minimumDistance: 0) + .updating($dragOffset) { value, dragState, transaction in + transaction.disablesAnimations = true + dragState = min(max(value.translation.width, 0), maxX) + } + .onEnded { value in + let offset = min(max(value.translation.width, 0), maxX) + let velocity = value.predictedEndLocation.x - value.location.x + let percent = maxX > 0 ? offset / maxX : 0 + guard SwipeControl.shouldCommit(percent: percent, velocity: velocity) else { + releaseCount += 1 + return } - ) - .disabled(!isNormal) + onCommit() + } + ) + .disabled(!isNormal) + // Reduce Motion swaps the thumb by identity at commit and reset, so the park and + // the return after a send crossfade rather than travel. + .id(reduceMotion ? isNormal : true) + .transition(.opacity) } + .animation(reduceMotion ? .easeInOut(duration: SwipeMetrics.reduceMotionFade) : nil, value: isNormal) .frame(maxWidth: .infinity, maxHeight: .infinity) + // A probe of its own, so the progress's animation never reaches the label or thumb. + .background { + Color.clear + .modifier(SwipeProgressReporter(progress: maxX > 0 ? min(max(position / maxX, 0), 1) : 0)) + .animation(reduceMotion ? .easeOut(duration: SwipeMetrics.reduceMotionFade) : snap, value: position) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + .onChange(of: wantsLift) { _, lift in + withAnimation(press) { isLifted = lift } + } + .sensoryFeedback(SwipeMetrics.pressHaptic, trigger: wantsLift) { wasLifted, lift in + !wasLifted && lift + } + .sensoryFeedback(SwipeMetrics.releaseHaptic, trigger: releaseCount) .task { do { try await Task.delay(seconds: 3) @@ -182,16 +278,58 @@ private struct SwipingTrack: View { } } } + + // Every swap between the arrow and the progress snaps. + private var thumbIcon: some View { + ZStack { + switch state { + case .normal: + Image.icon(.arrowRight) + .iconSize(SwipeMetrics.arrowSize) + .foregroundStyle(.textMain) + case .loading: + ButtonStateLabel(text, state: .loading) + .tint(.textSecondary) + case .success: + ButtonStateLabel(text, state: .success) + case .failure: + Image.icon(.crossMedium) + .iconSize(SwipeMetrics.arrowSize) + .foregroundStyle(.textMain) + } + } + .animation(nil, value: state) + } } -private enum Layout { - static let height: CGFloat = Metrics.buttonHeight - static let knobSize = CGSize(width: 60, height: 52) - static let knobCornerRadius: CGFloat = 4 - static let arrowSize: CGFloat = 18 - static let nudgeOffset: CGFloat = 20 - static let disabledOpacity: CGFloat = 0.4 +/// How far a ``SwipeControl``'s thumb has travelled along its track, from 0 at rest to 1 at the end, +/// which it holds through loading, success and failure. Read it with `onPreferenceChange` to drive +/// visuals around the control; it follows the finger while dragging and the thumb's own motion after. +public struct SwipeControlProgressKey: PreferenceKey { + public static let defaultValue: CGFloat = 0 + + public static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +/// Publishes `progress` as ``SwipeControlProgressKey`` on every frame it animates through, so a +/// reader moves on the thumb's spring rather than jumping to the end value. +private struct SwipeProgressReporter: ViewModifier, Animatable { + + var progress: CGFloat + + nonisolated var animatableData: CGFloat { + get { progress } + set { progress = newValue } + } + + func body(content: Content) -> some View { + content.preference(key: SwipeControlProgressKey.self, value: progress) + } +} +nonisolated private enum Layout { static let commitFraction: CGFloat = 0.7 static let velocityAssistFraction: CGFloat = 0.5 static let velocityAssistMinimum: CGFloat = 200 @@ -201,17 +339,19 @@ private enum KnobState { case normal case loading case success + case failure } #Preview { Background(color: .backgroundMain) { - VStack(spacing: 40) { + VStack(spacing: 32) { SwipeControl(text: "Swipe to Send") { try await Task.delay(seconds: 2) } - SwipeControl(text: "Swipe to Pay") { + SwipeControl(text: "Swipe to Fail") { try await Task.delay(seconds: 1) + throw SwipeControlFailure() } SwipeControl(text: "Swipe to Send") { diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift new file mode 100644 index 000000000..73fa6fef1 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift @@ -0,0 +1,168 @@ +// +// SwipeControlLook.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI + +// MARK: - Thumb surface + +/// The thumb's surface with `content` on it. While idle on iOS 26 it is clear glass over the lens +/// with `content` above it; otherwise the composer's glass (a tinted material before iOS 26). +struct SwipeThumbSurface: View { + + let isIdle: Bool + let glassNamespace: Namespace.ID + @ViewBuilder let content: Content + + var body: some View { + GlassContainer { + surface + } + // Outside the container, which composites its glass above sibling content. + .overlay { + if showsLens { + face + } + } + } + + private var showsLens: Bool { + isIdle && SwipeMetrics.usesGlass + } + + private var face: some View { + content.frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private var surface: some View { + if showsLens { + if #available(iOS 26, *) { + lensGlass + } + } else { + face.composerGlass(in: Capsule(), id: glassID, namespace: glassNamespace, isInteractive: false) + } + } + + /// Clear glass over the lens; it shares an ID with the composer glass so the thumb morphs + /// between them at commit and reset. + @available(iOS 26, *) + private var lensGlass: some View { + Capsule() + .fill(.clear) + .glassEffect(.clear, in: .capsule) + .glassEffectID(glassID, in: glassNamespace) + } + + private var glassID: String { "thumb" } +} + +// MARK: - Effects + +/// Bends the content under a capsule at `centerX` with end-cap `radius` and a straight run of +/// `halfLength` either side (a circle at 0), through the `swipeLens` shader. +struct SwipeLensEffect: ViewModifier, Animatable { + + var centerX: CGFloat + var radius: CGFloat + var halfLength: CGFloat + let bend: CGFloat + let band: CGFloat + let zoom: CGFloat + let falloff: CGFloat + let aberration: CGFloat + let isEnabled: Bool + + nonisolated var animatableData: AnimatablePair> { + get { AnimatablePair(centerX, AnimatablePair(radius, halfLength)) } + set { + centerX = newValue.first + radius = newValue.second.first + halfLength = newValue.second.second + } + } + + func body(content: Content) -> some View { + content.layerEffect( + ShaderLibrary.bundle(.module).swipeLens( + .boundingRect, + .float(centerX), + .float(radius), + .float(max(halfLength, 0)), + .float(bend), + .float(radius * band), + .float(max(zoom, 0.01)), + .float(falloff), + .float(aberration) + ), + maxSampleOffset: CGSize(width: bend + aberration + (radius + halfLength) * (1 - 1 / max(zoom, 1)), height: bend + aberration + radius), + isEnabled: isEnabled + ) + } +} + +/// Dims the content and sweeps a bright, tilted band across it on a loop. The band runs while +/// `isRunning`, and pauses and fades away otherwise; with `isOn` off the content is left as is. +struct SwipeShimmer: ViewModifier { + + let isOn: Bool + let isRunning: Bool + + func body(content: Content) -> some View { + if isOn { + content + .opacity(SwipeMetrics.shimmerLabelOpacity) + .overlay { + content + .mask(alignment: .leading) { band } + .opacity(isRunning ? 1 : 0) + .animation(.easeOut(duration: SwipeMetrics.shimmerFade), value: isRunning) + } + } else { + content + } + } + + private var band: some View { + GeometryReader { proxy in + let width = SwipeMetrics.shimmerBandWidth + let travel = proxy.size.width + width * 2 + let period = SwipeMetrics.shimmerPeriod + + // Driven by the clock, so a body pass never restarts the sweep. + TimelineView(.animation(paused: !isRunning)) { context in + let elapsed = context.date.timeIntervalSinceReferenceDate + let progress = elapsed.truncatingRemainder(dividingBy: period) / period + + Rectangle() + .frame(width: width) + .blur(radius: SwipeMetrics.shimmerBlur) + .rotationEffect(.degrees(SwipeMetrics.shimmerAngle)) + .offset(x: travel * progress - width) + } + } + } +} + +/// A decaying side-to-side shake: each whole step of `phase` plays `count` swings of `distance`. +struct SwipeShake: GeometryEffect { + + var phase: Double + let distance: CGFloat + let count: Int + + nonisolated var animatableData: Double { + get { phase } + set { phase = newValue } + } + + nonisolated func effectValue(size: CGSize) -> ProjectionTransform { + let progress = phase - phase.rounded(.down) + let swing = sin(progress * Double(count) * 2 * .pi) * (1 - progress) + return ProjectionTransform(CGAffineTransform(translationX: distance * swing, y: 0)) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeLens.metal b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeLens.metal new file mode 100644 index 000000000..76c3e6250 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeLens.metal @@ -0,0 +1,57 @@ +// +// SwipeLens.metal +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#include +#include +using namespace metal; + +// The swipe control's lens: inside a capsule (a circle when `halfLength` is 0), a band just within the rim samples the layer from +// nearer the centre, so whatever passes under the rim stretches outward and bends with it. The +// pull is strongest at the rim and eases to nothing `band` points in; the middle is left alone. +// +// `bounds` is the layer's rect (SwiftUI's `.boundingRect`), so the lens sits on its vertical middle. +// `halfLength` is how far the capsule's straight run reaches either side of `centerX`. +[[ stitchable ]] half4 swipeLens( + float2 position, + SwiftUI::Layer layer, + float4 bounds, + float centerX, + float radius, + float halfLength, + float bend, + float band, + float zoom, + float falloff, + float aberration +) { + float centerY = bounds.y + bounds.w * 0.5; + // The nearest point on the capsule's spine, so the rim keeps one distance all the way round. + float2 center = float2(centerX + clamp(position.x - centerX, -halfLength, halfLength), centerY); + float2 fromCenter = position - center; + float distance = length(fromCenter); + + if (radius <= 0.0 || distance >= radius || distance < 0.001) { + return layer.sample(position); + } + + float insideRim = radius - distance; + float pull = 1.0 - smoothstep(0.0, max(band, 0.001), insideRim); + float2 outward = fromCenter / distance; + // Magnify about the lens's own centre, then bend; at zoom 1 and falloff 2 this is the original pull * pull. + float2 lensCenter = float2(centerX, centerY); + float2 zoomed = lensCenter + (position - lensCenter) / zoom; + float shaped = pow(pull, falloff); + float2 source = zoomed - outward * bend * shaped; + if (aberration <= 0.0) { + return layer.sample(source); + } + float2 split = outward * aberration * shaped; + half4 middle = layer.sample(source); + half4 red = layer.sample(source + split); + half4 blue = layer.sample(source - split); + return half4(red.r, middle.g, blue.b, max(max(red.a, blue.a), middle.a)); +} diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift new file mode 100644 index 000000000..01d2c7729 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift @@ -0,0 +1,124 @@ +// +// SwipeMetrics.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI + +/// The swipe control's look and feel values: the track, the lens thumb, the shimmer, the failure +/// beat, the haptics and every spring they move on. +/// +/// The commit thresholds are not here: they are behaviour, and stay in `SwipeControl`. +@MainActor +enum SwipeMetrics { + + // MARK: - Glass + + /// Whether the thumb is Liquid Glass with the lens; before iOS 26 it is a material thumb. + static var usesGlass: Bool { + if #available(iOS 26, *) { + true + } else { + false + } + } + + // MARK: - Track + + /// The capsule's height, which stands taller than the tray's capsule buttons. + static let height: CGFloat = 64 + /// The gap between the thumb and the track's edge. + static let thumbInset: CGFloat = 4 + /// The thumb's width; wider than the track is tall, so the thumb is a capsule. + static let thumbWidth: CGFloat = 84 + /// The thumb's arrow and failure cross. + static let arrowSize: CGFloat = 26 + /// The track's faint `action` wash. + static let trackFill: Color = .action.opacity(0.075) + /// The whole control's opacity while disabled. + static let disabledOpacity: Double = 0.4 + + // MARK: - Label + + /// How far the label may shrink to fit one line at large Dynamic Type sizes. + static let labelMinimumScale: CGFloat = 0.7 + + // MARK: - Thumb + + /// How far the idle nudge pushes the thumb right. + static let nudgeDistance: CGFloat = 16 + /// The thumb's scale under the finger, which lets the lens swell past the track's edge. + static let pressScale: CGFloat = 1.3 + /// The thumb's opacity under the finger with Reduce Motion on, standing in for the scale. + static let pressDim: Double = 0.8 + + // MARK: - Motion + + /// The thumb settling: snapping back from a short drag, parking at the end, returning after a send. + static let snapBack = ChatSpring(duration: 0.4, bounce: 0) + /// The thumb lifting under the finger and settling on release. + static let press = ChatSpring(duration: 0.3, bounce: 0.25) + /// The idle nudge's push and return. + static let nudge = ChatSpring(duration: 0.25, bounce: 0) + /// With Reduce Motion on, the crossfade that replaces the thumb's travel at commit and reset. + static let reduceMotionFade: TimeInterval = 0.2 + + // MARK: - Lens + + /// How far the lens pulls the label at the rim. + static let lensBend: CGFloat = 10 + /// How deep inside the rim the bend reaches, as a share of the lens's radius. + static let lensBand: CGFloat = 0.34 + /// How much the label is magnified inside the lens; 1 leaves it at size. + static let lensZoom: CGFloat = 1.19 + /// How sharply the bend falls off from the rim; higher keeps the distortion tighter to the edge. + static let lensFalloff: CGFloat = 1.4 + /// How far apart red and blue split where the lens bends, in points. + static let lensAberration: CGFloat = 1.3 + + // MARK: - Haptic + + /// The tap at touch-down. + static let pressHaptic: SensoryFeedback = .impact(flexibility: .soft, intensity: 1) + /// The tap when the finger lifts without committing. + static let releaseHaptic: SensoryFeedback = .impact(flexibility: .rigid, intensity: 0.175) + + // MARK: - Shimmer + + /// The bright band's width before its blur. + static let shimmerBandWidth: CGFloat = 30 + /// The band's tilt, in degrees. + static let shimmerAngle: Double = 15 + /// The band's blur radius, which softens its edges. + static let shimmerBlur: CGFloat = 5 + /// The label's opacity outside the band, which leaves the band room to read brighter. + static let shimmerLabelOpacity: Double = 0.6 + /// The time one sweep takes whatever the label's length, so longer labels sweep faster, as GSConfirm does. + static let shimmerPeriod: TimeInterval = 2.75 + /// The band fading away when the sweep pauses, and back when it resumes. + static let shimmerFade: TimeInterval = 0.2 + + // MARK: - Failure + + /// How far the failure shake swings either side. + static let shakeDistance: CGFloat = 8 + /// How many swings the failure shake plays. + static let shakeCount = 3 + /// How long the whole failure shake takes. + static let shakeDuration: TimeInterval = 0.4 + /// The `bannerError` wash the track takes while it shows the failure. + static let failureTint: Double = 0.35 + /// The failure tint fading in and out. + static let failureTintFade: TimeInterval = 0.15 + /// How long the failure shows before the control resets. + static let failureHold: TimeInterval = 0.6 +} + +extension ChatSpring { + /// This spring as an animation, with its bounce removed when Reduce Motion is on. + func animation(reduceMotion: Bool) -> Animation { + reduceMotion ? .spring(duration: duration, bounce: 0) : animation + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipePressDetector.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipePressDetector.swift new file mode 100644 index 000000000..39ead1fc5 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipePressDetector.swift @@ -0,0 +1,42 @@ +// +// SwipePressDetector.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import UIKit + +/// Reports touch-down and release on its view at once, alongside every other gesture, never blocking or delaying one. +struct SwipePressDetector: UIGestureRecognizerRepresentable { + let onChange: (Bool) -> Void + + func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer { + let recognizer = UILongPressGestureRecognizer() + recognizer.minimumPressDuration = 0 + recognizer.allowableMovement = .greatestFiniteMagnitude + recognizer.cancelsTouchesInView = false + recognizer.delaysTouchesBegan = false + recognizer.delaysTouchesEnded = false + recognizer.delegate = context.coordinator + return recognizer + } + + func handleUIGestureRecognizerAction(_ recognizer: UILongPressGestureRecognizer, context: Context) { + switch recognizer.state { + case .began: onChange(true) + case .ended, .cancelled, .failed: onChange(false) + case .possible, .changed: break + @unknown default: break + } + } + + func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { Coordinator() } + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { true } + func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldRequireFailureOf other: UIGestureRecognizer) -> Bool { false } + func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldBeRequiredToFailBy other: UIGestureRecognizer) -> Bool { false } + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift new file mode 100644 index 000000000..c0a22bf82 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift @@ -0,0 +1,106 @@ +// +// SwipeShowcase.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if DEBUG +import SwiftUI + +/// The swipe-to-send prototype, standing in for the whole app when launched with `--swipe-sandbox`. +/// +/// A launch argument for the same reason as the tray sandbox: one `simctl launch` line puts the +/// device on the swipe controls with no sign-in. DEBUG-only, so it can't ship. +public enum SwipeSandbox { + + /// Whether the app was launched with `--swipe-sandbox`. + @MainActor public static var isRequested: Bool { + ProcessInfo.processInfo.arguments.contains("--swipe-sandbox") + } +} + +/// The swipe control at the send screen's width, driven by a local closure that succeeds, fails +/// or is disabled, so it can be felt without sending. +public struct SwipeShowcaseScreen: View { + + @State private var outcome: SwipeShowcaseOutcome = .succeeds + @State private var isLongLabel = false + + /// Creates the showcase. + public init() {} + + public var body: some View { + Background(color: .backgroundMain) { + VStack(spacing: 0) { + controls + Spacer(minLength: 24) + SwipeControl(text: label, action: outcome.action) + .disabled(outcome == .disabled) + } + .padding(20) + } + .preferredColorScheme(.dark) + } + + // MARK: - Controls - + + private var controls: some View { + VStack(alignment: .leading, spacing: 16) { + Picker("Outcome", selection: $outcome) { + ForEach(SwipeShowcaseOutcome.allCases) { outcome in + Text(outcome.title).tag(outcome) + } + } + .pickerStyle(.segmented) + + Toggle("Long label", isOn: $isLongLabel) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + } + } + + private var label: String { + isLongLabel ? "Swipe to Send $1,250.00 to Alexandra" : "Swipe to Send" + } +} + +// MARK: - Outcome - + +/// What a showcase swipe does once it commits; every outcome is a local sleep, never a send. +public enum SwipeShowcaseOutcome: String, CaseIterable, Identifiable { + case succeeds + case fails + case disabled + + public var id: String { rawValue } + + /// The outcome's name in the showcase's picker. + public var title: String { + switch self { + case .succeeds: "Succeeds" + case .fails: "Fails" + case .disabled: "Disabled" + } + } + + /// The swipe's action: succeeds after a beat, or fails with ``SwipeControlFailure``. + public var action: ThrowingAction { + switch self { + case .succeeds, .disabled: + return { + try await Task.sleep(for: .seconds(1.5)) + } + case .fails: + return { + try await Task.sleep(for: .seconds(1)) + throw SwipeControlFailure() + } + } + } +} + +#Preview("Swipe showcase") { + SwipeShowcaseScreen() +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Views/KeyPadView.swift b/FlipcashUI/Sources/FlipcashUI/Views/KeyPadView.swift index bf0515127..07012d7a3 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/KeyPadView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/KeyPadView.swift @@ -311,6 +311,22 @@ public enum ButtonContent: RawRepresentable { extension ButtonContent { public enum Symbol: String { case chveronLeft = "chevron.left" + + /// The Central icon this key draws. + public var icon: Icon { + switch self { + case .chveronLeft: + return .chevronLeftMedium + } + } + + /// What VoiceOver calls this key. + var accessibilityLabel: LocalizedStringKey { + switch self { + case .chveronLeft: + return "Delete" + } + } } } @@ -364,7 +380,7 @@ private extension KeyPadView { } @ViewBuilder private func button(for content: ButtonContent) -> some View { - Button { + let button = Button { if let symbol = content.symbol { action(for: symbol) } else { @@ -372,12 +388,20 @@ private extension KeyPadView { } } label: { if let symbol = content.symbol { - Image(systemName: symbol.rawValue) + Image.icon(symbol.icon) + .iconSize(36) } else { Text(content.rawValue) } } .font(.appKeyboard) + + // A symbol key's icon is decorative, so the key names itself. + if let symbol = content.symbol { + button.accessibilityLabel(symbol.accessibilityLabel) + } else { + button + } } // MARK: - Actions - @@ -411,6 +435,8 @@ public enum ButtonAction { private struct KeyPadBorderlessStyle: ButtonStyle { private let size: CGSize + + @Environment(\.keyPadRestFill) private var restFill init(size: CGSize) { self.size = size @@ -419,14 +445,20 @@ private struct KeyPadBorderlessStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration .label - .scaleEffect(configuration.isPressed ? 1.5 : 1.0, anchor: .center) + .scaleEffect(configuration.isPressed ? 1.35 : 1.0, anchor: .center) .frame(width: size.width, height: size.height) // FIXME: Invalid size here sometimes - .background(configuration.isPressed ? Color.white.opacity(0.1) : Color.backgroundMain.opacity(0.1)) - .foregroundStyle(configuration.isPressed ? Color.white.opacity(0.9) : .white) + .background(configuration.isPressed ? Color.white.opacity(0.1) : restFill) + .foregroundStyle(configuration.isPressed ? Color.white : Color.white.opacity(0.8)) .clipShape(.rect(cornerRadius: 5)) } } +extension EnvironmentValues { + /// A key's fill at rest: a tint that disappears on the screen's background, or clear on a + /// lighter surface such as a tray, where the tint would show. + @Entry var keyPadRestFill: Color = Color.backgroundMain.opacity(0.1) +} + // MARK: - Previews - struct KeypadView_Previews: PreviewProvider { diff --git a/NotificationContent/NotificationViewController.swift b/NotificationContent/NotificationViewController.swift index dfcffafe5..a3fa398cc 100644 --- a/NotificationContent/NotificationViewController.swift +++ b/NotificationContent/NotificationViewController.swift @@ -25,10 +25,10 @@ final class NotificationViewController: UIViewController, UNNotificationContentE private let transcript = UIHostingController(rootView: NotificationTranscriptView(items: [])) private var statusLabel: UILabel? - /// The dark "background" color (display-P3 25,25,26), hardcoded because the matching asset + /// The dark "background" color (display-P3 18,18,18), hardcoded because the matching asset /// lives in the app bundle and can't resolve from this extension. private static let chatBackground = UIColor( - displayP3Red: 25 / 255, green: 25 / 255, blue: 26 / 255, alpha: 1 + displayP3Red: 18 / 255, green: 18 / 255, blue: 18 / 255, alpha: 1 ) /// The panel's fixed height. It never resizes to fit the transcript (which scrolls within