From a33f90797d995991c31a2b15dedafe855fb13a47 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 17:04:12 +0000 Subject: [PATCH 01/42] local snapshot: redesign working tree for swipe to send (never push) --- Flipcash/Core/AppDelegate.swift | 19 +- Flipcash/Core/FlipcashApp.swift | 56 +- .../Screens/Conversation/AttachMenu.swift | 27 +- .../Screens/Conversation/AttachSurface.swift | 6 +- .../Conversation/ChatCameraSheet.swift | 14 +- .../Screens/Conversation/ChatPhotosCard.swift | 4 +- .../Conversation/ComposerChipStrip.swift | 19 +- .../Conversation/ComposerReplyStrip.swift | 14 +- .../Conversation/ConversationBottomBar.swift | 71 +- .../Conversation/ConversationScreen.swift | 53 +- .../Conversation/ReportFlowScreen.swift | 3 +- .../Screens/Main/Buy/BuyAmountScreen.swift | 4 +- .../ConvertAmountScreen.swift | 4 +- .../CurrencyPickerSheet.swift | 4 +- .../CurrencyCreationWizardScreen.swift | 3 +- .../Currency Info/CurrencyInfoContentV2.swift | 39 +- .../Currency Info/CurrencyInfoErrorView.swift | 6 +- .../Currency Info/CurrencyInfoScreen.swift | 11 +- .../CurrencyInfoSocialLinksSection.swift | 2 +- .../CurrencySelectionScreen.swift | 87 ++- Flipcash/Core/Screens/Main/Home/HomeTab.swift | 28 +- .../Core/Screens/Main/Home/HomeTabBar.swift | 2 +- .../Core/Screens/Main/Home/HomeTabView.swift | 107 +-- .../Screens/Main/Home/NewUserTutorial.swift | 4 +- .../Main/Home/RecentActivitySection.swift | 4 +- .../Screens/Main/Home/TabBarBadgedIcon.swift | 14 +- .../Screens/Main/Home/TabBarItemStyle.swift | 108 +++ .../Main/Home/TransactionDetailsScreen.swift | 8 +- .../Core/Screens/Main/Home/WalletScreen.swift | 47 +- .../Screens/Main/SelectCurrencyScreen.swift | 3 +- .../Main/Tips/ArchivedChatsScreen.swift | 4 +- .../Tips/New Group/ShareToChatsSheet.swift | 2 +- .../Screens/Main/Tips/NewChatScreen.swift | 2 +- .../Core/Screens/Main/Tips/RecipientRow.swift | 126 +++- .../Main/Tips/TipConversationsScreen.swift | 57 +- .../Core/Screens/Main/You/YouScreen.swift | 5 +- .../Screens/Profile/ChatProfileScreen.swift | 9 +- .../Screens/Profile/E2eeLearnMoreSheet.swift | 141 ++-- .../Core/Screens/Profile/MuteChatSheet.swift | 111 ++- .../Screens/Profile/UserProfileScreen.swift | 4 +- .../SettingsAdvancedBetaFeaturesScreen.swift | 19 + .../Settings/SettingsAppSettingsScreen.swift | 2 +- .../Core/Screens/Settings/SettingsRow.swift | 43 +- .../Screens/Settings/SettingsScreen.swift | 27 +- .../Settings/SettingsSectionHeader.swift | 11 +- .../Core/Session/SessionAuthenticator.swift | 1 + Flipcash/UI/EnterAmountHeader.swift | 2 +- Flipcash/UI/EnterAmountView.swift | 49 +- Flipcash/UI/TokenSelectorButton.swift | 4 +- Flipcash/UI/View+Dialog.swift | 127 ++++ Flipcash/Views/TutorialChecklistCard.swift | 2 +- FlipcashTests/Tray/TrayFlowTests.swift | 169 +++++ FlipcashUI/Package.swift | 4 +- .../Airplane.imageset/Airplane.svg | 1 + .../Airplane.imageset/Contents.json | 16 + .../Archive.imageset/Archive.svg | 1 + .../Archive.imageset/Contents.json | 16 + .../ArrowBottomTop.svg | 1 + .../ArrowBottomTop.imageset/Contents.json | 16 + .../ArrowBoxLeft.imageset/ArrowBoxLeft.svg | 1 + .../ArrowBoxLeft.imageset/Contents.json | 16 + .../ArrowCornerLeftDown.svg | 1 + .../Contents.json | 16 + .../ArrowDown.imageset/ArrowDown.svg | 1 + .../ArrowDown.imageset/Contents.json | 16 + .../ArrowInbox.imageset/ArrowInbox.svg | 1 + .../ArrowInbox.imageset/Contents.json | 16 + .../ArrowLeft.imageset/ArrowLeft.svg | 1 + .../ArrowLeft.imageset/Contents.json | 16 + .../ArrowRight.imageset/ArrowRight.svg | 1 + .../ArrowRight.imageset/Contents.json | 16 + .../ArrowRotateClockwise.svg | 1 + .../Contents.json | 16 + .../ArrowShareLeft.svg | 1 + .../ArrowShareLeft.imageset/Contents.json | 16 + .../ArrowShareLeftFilled.svg | 1 + .../Contents.json | 16 + .../ArrowShareRight.svg | 1 + .../ArrowShareRight.imageset/Contents.json | 16 + .../ArrowUp.imageset/ArrowUp.svg | 1 + .../ArrowUp.imageset/Contents.json | 16 + .../ArrowUpWall.imageset/ArrowUpWall.svg | 1 + .../ArrowUpWall.imageset/Contents.json | 16 + .../ArrowWallDown.imageset/ArrowWallDown.svg | 1 + .../ArrowWallDown.imageset/Contents.json | 16 + .../ArrowsRepeat.imageset/ArrowsRepeat.svg | 1 + .../ArrowsRepeat.imageset/Contents.json | 16 + .../central-light/At.imageset/At.svg | 1 + .../central-light/At.imageset/Contents.json | 16 + .../Banknote1.imageset/Banknote1.svg | 1 + .../Banknote1.imageset/Contents.json | 16 + .../central-light/Bell2.imageset/Bell2.svg | 1 + .../Bell2.imageset/Contents.json | 16 + .../Bell2Off.imageset/Bell2Off.svg | 1 + .../Bell2Off.imageset/Contents.json | 16 + .../BellOff.imageset/BellOff.svg | 1 + .../BellOff.imageset/Contents.json | 16 + .../BezierCurve.imageset/BezierCurve.svg | 1 + .../BezierCurve.imageset/Contents.json | 16 + .../Bubble2.imageset/Bubble2.svg | 1 + .../Bubble2.imageset/Contents.json | 16 + .../BubbleAlert.imageset/BubbleAlert.svg | 1 + .../BubbleAlert.imageset/Contents.json | 16 + .../BubbleCrossed.imageset/BubbleCrossed.svg | 1 + .../BubbleCrossed.imageset/Contents.json | 16 + .../central-light/Bug.imageset/Bug.svg | 1 + .../central-light/Bug.imageset/Contents.json | 16 + .../Camera1.imageset/Camera1.svg | 1 + .../Camera1.imageset/Contents.json | 16 + .../ChainLink4.imageset/ChainLink4.svg | 1 + .../ChainLink4.imageset/Contents.json | 16 + .../ChatBubbles.imageset/ChatBubbles.svg | 1 + .../ChatBubbles.imageset/Contents.json | 16 + .../ChatBubblesFilled.svg | 1 + .../ChatBubblesFilled.imageset/Contents.json | 16 + .../Checklist.imageset/Checklist.svg | 1 + .../Checklist.imageset/Contents.json | 16 + .../Checkmark1.imageset/Checkmark1.svg | 1 + .../Checkmark1.imageset/Contents.json | 16 + .../ChevronDownMedium.svg | 1 + .../ChevronDownMedium.imageset/Contents.json | 16 + .../ChevronLeftMedium.svg | 1 + .../ChevronLeftMedium.imageset/Contents.json | 16 + .../ChevronRightMedium.svg | 1 + .../ChevronRightMedium.imageset/Contents.json | 16 + .../CircleArrowDown.svg | 1 + .../CircleArrowDown.imageset/Contents.json | 16 + .../CircleBanSign.imageset/CircleBanSign.svg | 1 + .../CircleBanSign.imageset/Contents.json | 16 + .../CircleCheck.imageset/CircleCheck.svg | 1 + .../CircleCheck.imageset/Contents.json | 16 + .../CircleDotted.imageset/CircleDotted.svg | 1 + .../CircleDotted.imageset/Contents.json | 16 + .../CircleInfo.imageset/CircleInfo.svg | 1 + .../CircleInfo.imageset/Contents.json | 16 + .../CircleMinus.imageset/CircleMinus.svg | 1 + .../CircleMinus.imageset/Contents.json | 16 + .../CirclePerson.imageset/CirclePerson.svg | 1 + .../CirclePerson.imageset/Contents.json | 16 + .../CirclePlus.imageset/CirclePlus.svg | 1 + .../CirclePlus.imageset/Contents.json | 16 + .../CircleX.imageset/CircleX.svg | 1 + .../CircleX.imageset/Contents.json | 16 + .../CircleXFilled.imageset/CircleXFilled.svg | 1 + .../CircleXFilled.imageset/Contents.json | 16 + .../Clipboard.imageset/Clipboard.svg | 1 + .../Clipboard.imageset/Contents.json | 16 + .../central-light/Clock.imageset/Clock.svg | 1 + .../Clock.imageset/Contents.json | 16 + .../central-light/Coins.imageset/Coins.svg | 1 + .../Coins.imageset/Contents.json | 16 + .../CoinsAdd.imageset/CoinsAdd.svg | 1 + .../CoinsAdd.imageset/Contents.json | 16 + .../UI.xcassets/central-light/Contents.json | 9 + .../CreditCard1.imageset/Contents.json | 16 + .../CreditCard1.imageset/CreditCard1.svg | 1 + .../CrossMedium.imageset/Contents.json | 16 + .../CrossMedium.imageset/CrossMedium.svg | 1 + .../Crown.imageset/Contents.json | 16 + .../central-light/Crown.imageset/Crown.svg | 1 + .../Contents.json | 16 + .../DotGrid1x3Horizontal.svg | 1 + .../DotGrid1x3Vertical.imageset/Contents.json | 16 + .../DotGrid1x3Vertical.svg | 1 + .../Email1.imageset/Contents.json | 16 + .../central-light/Email1.imageset/Email1.svg | 1 + .../EmojiAddReaction.imageset/Contents.json | 16 + .../EmojiAddReaction.svg | 1 + .../ExclamationCircle.imageset/Contents.json | 16 + .../ExclamationCircle.svg | 1 + .../Contents.json | 16 + .../ExclamationTriangle.svg | 1 + .../FaceId.imageset/Contents.json | 16 + .../central-light/FaceId.imageset/FaceId.svg | 1 + .../FileBend.imageset/Contents.json | 16 + .../FileBend.imageset/FileBend.svg | 1 + .../FileDownload.imageset/Contents.json | 16 + .../FileDownload.imageset/FileDownload.svg | 1 + .../FileText.imageset/Contents.json | 16 + .../FileText.imageset/FileText.svg | 1 + .../FingerPrint1.imageset/Contents.json | 16 + .../FingerPrint1.imageset/FingerPrint1.svg | 1 + .../Flag1.imageset/Contents.json | 16 + .../central-light/Flag1.imageset/Flag1.svg | 1 + .../Folder1.imageset/Contents.json | 16 + .../Folder1.imageset/Folder1.svg | 1 + .../Gift1.imageset/Contents.json | 16 + .../central-light/Gift1.imageset/Gift1.svg | 1 + .../Globe.imageset/Contents.json | 16 + .../central-light/Globe.imageset/Globe.svg | 1 + .../central-light/Grid.imageset/Contents.json | 16 + .../central-light/Grid.imageset/Grid.svg | 1 + .../Group1.imageset/Contents.json | 16 + .../central-light/Group1.imageset/Group1.svg | 1 + .../Group3.imageset/Contents.json | 16 + .../central-light/Group3.imageset/Group3.svg | 1 + .../Hand5Finger.imageset/Contents.json | 16 + .../Hand5Finger.imageset/Hand5Finger.svg | 1 + .../History.imageset/Contents.json | 16 + .../History.imageset/History.svg | 1 + .../Images1.imageset/Contents.json | 16 + .../Images1.imageset/Images1.svg | 1 + .../Images3.imageset/Contents.json | 16 + .../Images3.imageset/Images3.svg | 1 + .../Invite.imageset/Contents.json | 16 + .../central-light/Invite.imageset/Invite.svg | 1 + .../central-light/Key1.imageset/Contents.json | 16 + .../central-light/Key1.imageset/Key1.svg | 1 + .../KikCode.imageset/Contents.json | 16 + .../KikCode.imageset/KikCode.svg | 15 + .../KikCodeFilled.imageset/Contents.json | 16 + .../KikCodeFilled.imageset/KikCodeFilled.svg | 15 + .../LightningBolt.imageset/Contents.json | 16 + .../LightningBolt.imageset/LightningBolt.svg | 1 + .../LightningBoltOff.imageset/Contents.json | 16 + .../LightningBoltOff.svg | 1 + .../central-light/Lock.imageset/Contents.json | 16 + .../central-light/Lock.imageset/Lock.svg | 1 + .../MagnifyingGlass.imageset/Contents.json | 16 + .../MagnifyingGlass.svg | 1 + .../Maintenance.imageset/Contents.json | 16 + .../Maintenance.imageset/Maintenance.svg | 1 + .../PaperPlaneTopRight.imageset/Contents.json | 16 + .../PaperPlaneTopRight.svg | 1 + .../Pencil2.imageset/Contents.json | 16 + .../Pencil2.imageset/Pencil2.svg | 1 + .../People.imageset/Contents.json | 16 + .../central-light/People.imageset/People.svg | 1 + .../PeopleCircle.imageset/Contents.json | 16 + .../PeopleCircle.imageset/PeopleCircle.svg | 1 + .../PeopleCircleFilled.imageset/Contents.json | 16 + .../PeopleCircleFilled.svg | 1 + .../PeopleGear.imageset/Contents.json | 16 + .../PeopleGear.imageset/PeopleGear.svg | 1 + .../PeopleIdCard.imageset/Contents.json | 16 + .../PeopleIdCard.imageset/PeopleIdCard.svg | 1 + .../Phone.imageset/Contents.json | 16 + .../central-light/Phone.imageset/Phone.svg | 1 + .../PlusLarge.imageset/Contents.json | 16 + .../PlusLarge.imageset/PlusLarge.svg | 1 + .../QrCode.imageset/Contents.json | 16 + .../central-light/QrCode.imageset/QrCode.svg | 1 + .../QuestionmarkCircle.imageset/Contents.json | 16 + .../QuestionmarkCircle.svg | 1 + .../central-light/Send.imageset/Contents.json | 16 + .../central-light/Send.imageset/Send.svg | 1 + .../SettingsGear2.imageset/Contents.json | 16 + .../SettingsGear2.imageset/SettingsGear2.svg | 1 + .../Contents.json | 16 + .../SettingsSliderThree.svg | 1 + .../ShareOs.imageset/Contents.json | 16 + .../ShareOs.imageset/ShareOs.svg | 1 + .../Contents.json | 16 + .../SquareBehindSquare1.svg | 1 + .../SquareX.imageset/Contents.json | 16 + .../SquareX.imageset/SquareX.svg | 1 + .../TextSize.imageset/Contents.json | 16 + .../TextSize.imageset/TextSize.svg | 1 + .../TrashCan.imageset/Contents.json | 16 + .../TrashCan.imageset/TrashCan.svg | 1 + .../Trophy.imageset/Contents.json | 16 + .../central-light/Trophy.imageset/Trophy.svg | 1 + .../central-light/User.imageset/Contents.json | 16 + .../central-light/User.imageset/User.svg | 1 + .../UserAdd.imageset/Contents.json | 16 + .../UserAdd.imageset/UserAdd.svg | 1 + .../VolumeOff.imageset/Contents.json | 16 + .../VolumeOff.imageset/VolumeOff.svg | 1 + .../Wallet1.imageset/Contents.json | 16 + .../Wallet1.imageset/Wallet1.svg | 1 + .../Wallet5.imageset/Contents.json | 16 + .../Wallet5.imageset/Wallet5.svg | 1 + .../Wallet5Filled.imageset/Contents.json | 16 + .../Wallet5Filled.imageset/Wallet5Filled.svg | 1 + .../central/Airplane.imageset/Airplane.svg | 1 + .../central/Airplane.imageset/Contents.json | 16 + .../central/Archive.imageset/Archive.svg | 1 + .../central/Archive.imageset/Contents.json | 16 + .../ArrowBottomTop.svg | 1 + .../ArrowBottomTop.imageset/Contents.json | 16 + .../ArrowBoxLeft.imageset/ArrowBoxLeft.svg | 1 + .../ArrowBoxLeft.imageset/Contents.json | 16 + .../ArrowCornerLeftDown.svg | 1 + .../Contents.json | 16 + .../central/ArrowDown.imageset/ArrowDown.svg | 1 + .../central/ArrowDown.imageset/Contents.json | 16 + .../ArrowInbox.imageset/ArrowInbox.svg | 1 + .../central/ArrowInbox.imageset/Contents.json | 16 + .../central/ArrowLeft.imageset/ArrowLeft.svg | 1 + .../central/ArrowLeft.imageset/Contents.json | 16 + .../ArrowRight.imageset/ArrowRight.svg | 1 + .../central/ArrowRight.imageset/Contents.json | 16 + .../ArrowRotateClockwise.svg | 1 + .../Contents.json | 16 + .../ArrowShareLeft.svg | 1 + .../ArrowShareLeft.imageset/Contents.json | 16 + .../ArrowShareLeftFilled.svg | 1 + .../Contents.json | 16 + .../ArrowShareRight.svg | 1 + .../ArrowShareRight.imageset/Contents.json | 16 + .../central/ArrowUp.imageset/ArrowUp.svg | 1 + .../central/ArrowUp.imageset/Contents.json | 16 + .../ArrowUpWall.imageset/ArrowUpWall.svg | 1 + .../ArrowUpWall.imageset/Contents.json | 16 + .../ArrowWallDown.imageset/ArrowWallDown.svg | 1 + .../ArrowWallDown.imageset/Contents.json | 16 + .../ArrowsRepeat.imageset/ArrowsRepeat.svg | 1 + .../ArrowsRepeat.imageset/Contents.json | 16 + .../UI.xcassets/central/At.imageset/At.svg | 1 + .../central/At.imageset/Contents.json | 16 + .../central/Banknote1.imageset/Banknote1.svg | 1 + .../central/Banknote1.imageset/Contents.json | 16 + .../central/Bell2.imageset/Bell2.svg | 1 + .../central/Bell2.imageset/Contents.json | 16 + .../central/Bell2Off.imageset/Bell2Off.svg | 1 + .../central/Bell2Off.imageset/Contents.json | 16 + .../central/BellOff.imageset/BellOff.svg | 1 + .../central/BellOff.imageset/Contents.json | 16 + .../BezierCurve.imageset/BezierCurve.svg | 1 + .../BezierCurve.imageset/Contents.json | 16 + .../central/Bubble2.imageset/Bubble2.svg | 1 + .../central/Bubble2.imageset/Contents.json | 16 + .../BubbleAlert.imageset/BubbleAlert.svg | 1 + .../BubbleAlert.imageset/Contents.json | 16 + .../BubbleCrossed.imageset/BubbleCrossed.svg | 1 + .../BubbleCrossed.imageset/Contents.json | 16 + .../UI.xcassets/central/Bug.imageset/Bug.svg | 1 + .../central/Bug.imageset/Contents.json | 16 + .../central/Camera1.imageset/Camera1.svg | 1 + .../central/Camera1.imageset/Contents.json | 16 + .../ChainLink4.imageset/ChainLink4.svg | 1 + .../central/ChainLink4.imageset/Contents.json | 16 + .../ChatBubbles.imageset/ChatBubbles.svg | 1 + .../ChatBubbles.imageset/Contents.json | 16 + .../ChatBubblesFilled.svg | 1 + .../ChatBubblesFilled.imageset/Contents.json | 16 + .../central/Checklist.imageset/Checklist.svg | 1 + .../central/Checklist.imageset/Contents.json | 16 + .../Checkmark1.imageset/Checkmark1.svg | 1 + .../central/Checkmark1.imageset/Contents.json | 16 + .../ChevronDownMedium.svg | 1 + .../ChevronDownMedium.imageset/Contents.json | 16 + .../ChevronLeftMedium.svg | 1 + .../ChevronLeftMedium.imageset/Contents.json | 16 + .../ChevronRightMedium.svg | 1 + .../ChevronRightMedium.imageset/Contents.json | 16 + .../CircleArrowDown.svg | 1 + .../CircleArrowDown.imageset/Contents.json | 16 + .../CircleBanSign.imageset/CircleBanSign.svg | 1 + .../CircleBanSign.imageset/Contents.json | 16 + .../CircleCheck.imageset/CircleCheck.svg | 1 + .../CircleCheck.imageset/Contents.json | 16 + .../CircleDotted.imageset/CircleDotted.svg | 1 + .../CircleDotted.imageset/Contents.json | 16 + .../CircleInfo.imageset/CircleInfo.svg | 1 + .../central/CircleInfo.imageset/Contents.json | 16 + .../CircleMinus.imageset/CircleMinus.svg | 1 + .../CircleMinus.imageset/Contents.json | 16 + .../CirclePerson.imageset/CirclePerson.svg | 1 + .../CirclePerson.imageset/Contents.json | 16 + .../CirclePlus.imageset/CirclePlus.svg | 1 + .../central/CirclePlus.imageset/Contents.json | 16 + .../central/CircleX.imageset/CircleX.svg | 1 + .../central/CircleX.imageset/Contents.json | 16 + .../CircleXFilled.imageset/CircleXFilled.svg | 1 + .../CircleXFilled.imageset/Contents.json | 16 + .../central/Clipboard.imageset/Clipboard.svg | 1 + .../central/Clipboard.imageset/Contents.json | 16 + .../central/Clock.imageset/Clock.svg | 1 + .../central/Clock.imageset/Contents.json | 16 + .../central/Coins.imageset/Coins.svg | 1 + .../central/Coins.imageset/Contents.json | 16 + .../central/CoinsAdd.imageset/CoinsAdd.svg | 1 + .../central/CoinsAdd.imageset/Contents.json | 16 + .../Assets/UI.xcassets/central/Contents.json | 9 + .../CreditCard1.imageset/Contents.json | 16 + .../CreditCard1.imageset/CreditCard1.svg | 1 + .../CrossMedium.imageset/Contents.json | 16 + .../CrossMedium.imageset/CrossMedium.svg | 1 + .../central/Crown.imageset/Contents.json | 16 + .../central/Crown.imageset/Crown.svg | 1 + .../Contents.json | 16 + .../DotGrid1x3Horizontal.svg | 1 + .../DotGrid1x3Vertical.imageset/Contents.json | 16 + .../DotGrid1x3Vertical.svg | 1 + .../central/Email1.imageset/Contents.json | 16 + .../central/Email1.imageset/Email1.svg | 1 + .../EmojiAddReaction.imageset/Contents.json | 16 + .../EmojiAddReaction.svg | 1 + .../ExclamationCircle.imageset/Contents.json | 16 + .../ExclamationCircle.svg | 1 + .../Contents.json | 16 + .../ExclamationTriangle.svg | 1 + .../central/FaceId.imageset/Contents.json | 16 + .../central/FaceId.imageset/FaceId.svg | 1 + .../central/FileBend.imageset/Contents.json | 16 + .../central/FileBend.imageset/FileBend.svg | 1 + .../FileDownload.imageset/Contents.json | 16 + .../FileDownload.imageset/FileDownload.svg | 1 + .../central/FileText.imageset/Contents.json | 16 + .../central/FileText.imageset/FileText.svg | 1 + .../FingerPrint1.imageset/Contents.json | 16 + .../FingerPrint1.imageset/FingerPrint1.svg | 1 + .../central/Flag1.imageset/Contents.json | 16 + .../central/Flag1.imageset/Flag1.svg | 1 + .../central/Folder1.imageset/Contents.json | 16 + .../central/Folder1.imageset/Folder1.svg | 1 + .../central/Gift1.imageset/Contents.json | 16 + .../central/Gift1.imageset/Gift1.svg | 1 + .../central/Globe.imageset/Contents.json | 16 + .../central/Globe.imageset/Globe.svg | 1 + .../central/Grid.imageset/Contents.json | 16 + .../central/Grid.imageset/Grid.svg | 1 + .../central/Group1.imageset/Contents.json | 16 + .../central/Group1.imageset/Group1.svg | 1 + .../central/Group3.imageset/Contents.json | 16 + .../central/Group3.imageset/Group3.svg | 1 + .../Hand5Finger.imageset/Contents.json | 16 + .../Hand5Finger.imageset/Hand5Finger.svg | 1 + .../central/History.imageset/Contents.json | 16 + .../central/History.imageset/History.svg | 1 + .../central/Images1.imageset/Contents.json | 16 + .../central/Images1.imageset/Images1.svg | 1 + .../central/Images3.imageset/Contents.json | 16 + .../central/Images3.imageset/Images3.svg | 1 + .../central/Invite.imageset/Contents.json | 16 + .../central/Invite.imageset/Invite.svg | 1 + .../central/Key1.imageset/Contents.json | 16 + .../central/Key1.imageset/Key1.svg | 1 + .../central/KikCode.imageset/Contents.json | 16 + .../central/KikCode.imageset/KikCode.svg | 15 + .../KikCodeFilled.imageset/Contents.json | 16 + .../KikCodeFilled.imageset/KikCodeFilled.svg | 15 + .../LightningBolt.imageset/Contents.json | 16 + .../LightningBolt.imageset/LightningBolt.svg | 1 + .../LightningBoltOff.imageset/Contents.json | 16 + .../LightningBoltOff.svg | 1 + .../central/Lock.imageset/Contents.json | 16 + .../central/Lock.imageset/Lock.svg | 4 + .../MagnifyingGlass.imageset/Contents.json | 16 + .../MagnifyingGlass.svg | 1 + .../Maintenance.imageset/Contents.json | 16 + .../Maintenance.imageset/Maintenance.svg | 1 + .../PaperPlaneTopRight.imageset/Contents.json | 16 + .../PaperPlaneTopRight.svg | 1 + .../central/Pencil2.imageset/Contents.json | 16 + .../central/Pencil2.imageset/Pencil2.svg | 1 + .../central/People.imageset/Contents.json | 16 + .../central/People.imageset/People.svg | 1 + .../PeopleCircle.imageset/Contents.json | 16 + .../PeopleCircle.imageset/PeopleCircle.svg | 1 + .../PeopleCircleFilled.imageset/Contents.json | 16 + .../PeopleCircleFilled.svg | 1 + .../central/PeopleGear.imageset/Contents.json | 16 + .../PeopleGear.imageset/PeopleGear.svg | 1 + .../PeopleIdCard.imageset/Contents.json | 16 + .../PeopleIdCard.imageset/PeopleIdCard.svg | 1 + .../central/Phone.imageset/Contents.json | 16 + .../central/Phone.imageset/Phone.svg | 1 + .../central/PlusLarge.imageset/Contents.json | 16 + .../central/PlusLarge.imageset/PlusLarge.svg | 1 + .../central/QrCode.imageset/Contents.json | 16 + .../central/QrCode.imageset/QrCode.svg | 1 + .../QuestionmarkCircle.imageset/Contents.json | 16 + .../QuestionmarkCircle.svg | 1 + .../central/Send.imageset/Contents.json | 16 + .../central/Send.imageset/Send.svg | 1 + .../SettingsGear2.imageset/Contents.json | 16 + .../SettingsGear2.imageset/SettingsGear2.svg | 1 + .../Contents.json | 16 + .../SettingsSliderThree.svg | 1 + .../central/ShareOs.imageset/Contents.json | 16 + .../central/ShareOs.imageset/ShareOs.svg | 1 + .../Contents.json | 16 + .../SquareBehindSquare1.svg | 1 + .../central/SquareX.imageset/Contents.json | 16 + .../central/SquareX.imageset/SquareX.svg | 1 + .../central/TextSize.imageset/Contents.json | 16 + .../central/TextSize.imageset/TextSize.svg | 1 + .../central/TrashCan.imageset/Contents.json | 16 + .../central/TrashCan.imageset/TrashCan.svg | 1 + .../central/Trophy.imageset/Contents.json | 16 + .../central/Trophy.imageset/Trophy.svg | 1 + .../central/User.imageset/Contents.json | 16 + .../central/User.imageset/User.svg | 1 + .../central/UserAdd.imageset/Contents.json | 16 + .../central/UserAdd.imageset/UserAdd.svg | 1 + .../central/VolumeOff.imageset/Contents.json | 16 + .../central/VolumeOff.imageset/VolumeOff.svg | 1 + .../central/Wallet1.imageset/Contents.json | 16 + .../central/Wallet1.imageset/Wallet1.svg | 1 + .../central/Wallet5.imageset/Contents.json | 16 + .../central/Wallet5.imageset/Wallet5.svg | 1 + .../Wallet5Filled.imageset/Contents.json | 16 + .../Wallet5Filled.imageset/Wallet5Filled.svg | 1 + .../icons/IconLock.imageset/IconLock.svg | 3 +- .../Chat/BubbleBackgroundView.swift | 46 +- .../Chat/ChatAuthorAvatarView.swift | 19 +- .../FlipcashUI/Chat/ChatBackdrop.swift | 13 +- .../FlipcashUI/Chat/ChatBubbleView.swift | 27 +- .../FlipcashUI/Chat/ChatCashCardCell.swift | 138 ++-- .../FlipcashUI/Chat/ChatColumnCell.swift | 25 +- .../Chat/ChatDateSeparatorCell.swift | 20 +- .../Chat/ChatEncryptionMarkerCell.swift | 12 +- .../Chat/ChatMediaViewerController.swift | 12 +- .../FlipcashUI/Chat/ChatMotionSandbox.swift | 13 +- .../FlipcashUI/Chat/ChatQuotePanelView.swift | 14 +- .../Chat/ChatScreenViewController.swift | 30 +- .../Chat/ChatShareProfileCell.swift | 3 +- .../Sources/FlipcashUI/Chat/ChatStyle.swift | 306 ++++++++ .../FlipcashUI/Chat/ChatSwipeToReply.swift | 13 +- .../Chat/ChatSystemMessageCell.swift | 5 +- .../Chat/ChatTypingIndicatorCell.swift | 2 +- .../FlipcashUI/Chat/ChatViewController.swift | 66 +- .../FlipcashUI/Chat/ComposerFade.swift | 5 + .../FlipcashUI/Chat/EditedMarker.swift | 2 +- .../Chat/Emoji/EmojiPickerSheet.swift | 6 +- .../FlipcashUI/Chat/JumpToBottomModel.swift | 10 +- .../FlipcashUI/Chat/LinkMessageCardView.swift | 4 +- .../FlipcashUI/Chat/LinkableBubbleView.swift | 66 +- .../FlipcashUI/Chat/MessageBackdrop.swift | 14 +- .../FlipcashUI/Chat/MessageLinkBackChip.swift | 4 +- .../FlipcashUI/Chat/ReactionPillRowView.swift | 28 +- .../FlipcashUI/Chat/ReactionPillView.swift | 60 +- .../FlipcashUI/Chat/ReactionStripView.swift | 8 +- .../Sources/FlipcashUI/Theme/Colors.swift | 2 +- .../Sources/FlipcashUI/Theme/FontBook.swift | 54 +- .../Sources/FlipcashUI/Theme/Image+Icon.swift | 145 ++++ .../Sources/FlipcashUI/Tray/TrayAction.swift | 166 +++++ .../FlipcashUI/Tray/TrayAmountEntry.swift | 77 ++ .../FlipcashUI/Tray/TrayAmountMetrics.swift | 29 + .../FlipcashUI/Tray/TrayCloseButton.swift | 78 ++ .../Sources/FlipcashUI/Tray/TrayFlow.swift | 59 ++ .../Sources/FlipcashUI/Tray/TrayIcon.swift | 64 ++ .../Sources/FlipcashUI/Tray/TrayMetrics.swift | 192 +++++ .../Sources/FlipcashUI/Tray/TrayMotion.swift | 136 ++++ .../FlipcashUI/Tray/TrayOptionRow.swift | 102 +++ .../FlipcashUI/Tray/TrayOptionTile.swift | 69 ++ .../Sources/FlipcashUI/Tray/TrayPage.swift | 99 +++ .../FlipcashUI/Tray/TrayPresentation.swift | 143 ++++ .../Sources/FlipcashUI/Tray/TraySandbox.swift | 683 ++++++++++++++++++ .../FlipcashUI/Tray/TraySheetDriver.swift | 231 ++++++ .../Sources/FlipcashUI/Tray/TrayStack.swift | 232 ++++++ .../Sources/FlipcashUI/Tray/TrayStyle.swift | 40 + .../FlipcashUI/Views/AmountField.swift | 4 +- .../Sources/FlipcashUI/Views/AmountText.swift | 4 +- .../Views/ButtonStyles/IconButtonStyle.swift | 17 +- .../Views/Contacts/ContactAvatarView.swift | 1 + .../FlipcashUI/Views/Containers/ListRow.swift | 219 ++++++ .../Views/Controls/SwipeControl.swift | 10 +- .../Sources/FlipcashUI/Views/KeyPadView.swift | 25 +- 551 files changed, 8597 insertions(+), 767 deletions(-) create mode 100644 Flipcash/Core/Screens/Main/Home/TabBarItemStyle.swift create mode 100644 FlipcashTests/Tray/TrayFlowTests.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Airplane.imageset/Airplane.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Airplane.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Archive.imageset/Archive.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Archive.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowBottomTop.imageset/ArrowBottomTop.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowBottomTop.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowBoxLeft.imageset/ArrowBoxLeft.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowBoxLeft.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowCornerLeftDown.imageset/ArrowCornerLeftDown.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowCornerLeftDown.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowDown.imageset/ArrowDown.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowDown.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowInbox.imageset/ArrowInbox.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowInbox.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowLeft.imageset/ArrowLeft.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowLeft.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowRight.imageset/ArrowRight.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowRight.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowRotateClockwise.imageset/ArrowRotateClockwise.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowRotateClockwise.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareLeft.imageset/ArrowShareLeft.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareLeft.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareLeftFilled.imageset/ArrowShareLeftFilled.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareLeftFilled.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareRight.imageset/ArrowShareRight.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowShareRight.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowUp.imageset/ArrowUp.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowUp.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowUpWall.imageset/ArrowUpWall.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowUpWall.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowWallDown.imageset/ArrowWallDown.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowWallDown.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowsRepeat.imageset/ArrowsRepeat.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ArrowsRepeat.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/At.imageset/At.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/At.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Banknote1.imageset/Banknote1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Banknote1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bell2.imageset/Bell2.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bell2.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bell2Off.imageset/Bell2Off.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bell2Off.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BellOff.imageset/BellOff.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BellOff.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BezierCurve.imageset/BezierCurve.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BezierCurve.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bubble2.imageset/Bubble2.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bubble2.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BubbleAlert.imageset/BubbleAlert.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BubbleAlert.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BubbleCrossed.imageset/BubbleCrossed.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/BubbleCrossed.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bug.imageset/Bug.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Bug.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Camera1.imageset/Camera1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Camera1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChainLink4.imageset/ChainLink4.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChainLink4.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChatBubbles.imageset/ChatBubbles.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChatBubbles.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChatBubblesFilled.imageset/ChatBubblesFilled.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChatBubblesFilled.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Checklist.imageset/Checklist.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Checklist.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Checkmark1.imageset/Checkmark1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Checkmark1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronDownMedium.imageset/ChevronDownMedium.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronDownMedium.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronLeftMedium.imageset/ChevronLeftMedium.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronLeftMedium.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronRightMedium.imageset/ChevronRightMedium.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ChevronRightMedium.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleArrowDown.imageset/CircleArrowDown.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleArrowDown.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleBanSign.imageset/CircleBanSign.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleBanSign.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleCheck.imageset/CircleCheck.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleCheck.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleDotted.imageset/CircleDotted.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleDotted.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleInfo.imageset/CircleInfo.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleInfo.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleMinus.imageset/CircleMinus.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleMinus.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CirclePerson.imageset/CirclePerson.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CirclePerson.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CirclePlus.imageset/CirclePlus.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CirclePlus.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleX.imageset/CircleX.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleX.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleXFilled.imageset/CircleXFilled.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CircleXFilled.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Clipboard.imageset/Clipboard.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Clipboard.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Clock.imageset/Clock.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Clock.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Coins.imageset/Coins.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Coins.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CoinsAdd.imageset/CoinsAdd.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CoinsAdd.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CreditCard1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CreditCard1.imageset/CreditCard1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CrossMedium.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/CrossMedium.imageset/CrossMedium.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Crown.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Crown.imageset/Crown.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/DotGrid1x3Horizontal.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/DotGrid1x3Horizontal.imageset/DotGrid1x3Horizontal.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/DotGrid1x3Vertical.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/DotGrid1x3Vertical.imageset/DotGrid1x3Vertical.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Email1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Email1.imageset/Email1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/EmojiAddReaction.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/EmojiAddReaction.imageset/EmojiAddReaction.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ExclamationCircle.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ExclamationCircle.imageset/ExclamationCircle.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ExclamationTriangle.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ExclamationTriangle.imageset/ExclamationTriangle.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FaceId.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FaceId.imageset/FaceId.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileBend.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileBend.imageset/FileBend.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileDownload.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileDownload.imageset/FileDownload.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileText.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FileText.imageset/FileText.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FingerPrint1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/FingerPrint1.imageset/FingerPrint1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Flag1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Flag1.imageset/Flag1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Folder1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Folder1.imageset/Folder1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Gift1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Gift1.imageset/Gift1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Globe.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Globe.imageset/Globe.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Grid.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Grid.imageset/Grid.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Group1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Group1.imageset/Group1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Group3.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Group3.imageset/Group3.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Hand5Finger.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Hand5Finger.imageset/Hand5Finger.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/History.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/History.imageset/History.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Images1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Images1.imageset/Images1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Images3.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Images3.imageset/Images3.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Invite.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Invite.imageset/Invite.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Key1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Key1.imageset/Key1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/KikCode.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/KikCode.imageset/KikCode.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/KikCodeFilled.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/KikCodeFilled.imageset/KikCodeFilled.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/LightningBolt.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/LightningBolt.imageset/LightningBolt.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/LightningBoltOff.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/LightningBoltOff.imageset/LightningBoltOff.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Lock.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Lock.imageset/Lock.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/MagnifyingGlass.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/MagnifyingGlass.imageset/MagnifyingGlass.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Maintenance.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Maintenance.imageset/Maintenance.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PaperPlaneTopRight.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PaperPlaneTopRight.imageset/PaperPlaneTopRight.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Pencil2.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Pencil2.imageset/Pencil2.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/People.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/People.imageset/People.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleCircle.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleCircle.imageset/PeopleCircle.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleCircleFilled.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleCircleFilled.imageset/PeopleCircleFilled.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleGear.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleGear.imageset/PeopleGear.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleIdCard.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PeopleIdCard.imageset/PeopleIdCard.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Phone.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Phone.imageset/Phone.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PlusLarge.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/PlusLarge.imageset/PlusLarge.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/QrCode.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/QrCode.imageset/QrCode.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/QuestionmarkCircle.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/QuestionmarkCircle.imageset/QuestionmarkCircle.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Send.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Send.imageset/Send.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SettingsGear2.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SettingsGear2.imageset/SettingsGear2.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SettingsSliderThree.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SettingsSliderThree.imageset/SettingsSliderThree.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ShareOs.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/ShareOs.imageset/ShareOs.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SquareBehindSquare1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SquareBehindSquare1.imageset/SquareBehindSquare1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SquareX.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/SquareX.imageset/SquareX.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/TextSize.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/TextSize.imageset/TextSize.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/TrashCan.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/TrashCan.imageset/TrashCan.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Trophy.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Trophy.imageset/Trophy.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/User.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/User.imageset/User.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/UserAdd.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/UserAdd.imageset/UserAdd.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/VolumeOff.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/VolumeOff.imageset/VolumeOff.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet1.imageset/Wallet1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet5.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet5.imageset/Wallet5.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet5Filled.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central-light/Wallet5Filled.imageset/Wallet5Filled.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Airplane.imageset/Airplane.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Airplane.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Archive.imageset/Archive.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Archive.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowBottomTop.imageset/ArrowBottomTop.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowBottomTop.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowBoxLeft.imageset/ArrowBoxLeft.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowBoxLeft.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowCornerLeftDown.imageset/ArrowCornerLeftDown.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowCornerLeftDown.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowDown.imageset/ArrowDown.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowDown.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowInbox.imageset/ArrowInbox.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowInbox.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowLeft.imageset/ArrowLeft.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowLeft.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowRight.imageset/ArrowRight.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowRight.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowRotateClockwise.imageset/ArrowRotateClockwise.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowRotateClockwise.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareLeft.imageset/ArrowShareLeft.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareLeft.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareLeftFilled.imageset/ArrowShareLeftFilled.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareLeftFilled.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareRight.imageset/ArrowShareRight.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowShareRight.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowUp.imageset/ArrowUp.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowUp.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowUpWall.imageset/ArrowUpWall.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowUpWall.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowWallDown.imageset/ArrowWallDown.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowWallDown.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowsRepeat.imageset/ArrowsRepeat.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ArrowsRepeat.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/At.imageset/At.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/At.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Banknote1.imageset/Banknote1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Banknote1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bell2.imageset/Bell2.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bell2.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bell2Off.imageset/Bell2Off.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bell2Off.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BellOff.imageset/BellOff.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BellOff.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BezierCurve.imageset/BezierCurve.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BezierCurve.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bubble2.imageset/Bubble2.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bubble2.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BubbleAlert.imageset/BubbleAlert.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BubbleAlert.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BubbleCrossed.imageset/BubbleCrossed.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/BubbleCrossed.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bug.imageset/Bug.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Bug.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Camera1.imageset/Camera1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Camera1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChainLink4.imageset/ChainLink4.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChainLink4.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChatBubbles.imageset/ChatBubbles.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChatBubbles.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChatBubblesFilled.imageset/ChatBubblesFilled.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChatBubblesFilled.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Checklist.imageset/Checklist.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Checklist.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Checkmark1.imageset/Checkmark1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Checkmark1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronDownMedium.imageset/ChevronDownMedium.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronDownMedium.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronLeftMedium.imageset/ChevronLeftMedium.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronLeftMedium.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronRightMedium.imageset/ChevronRightMedium.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ChevronRightMedium.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleArrowDown.imageset/CircleArrowDown.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleArrowDown.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleBanSign.imageset/CircleBanSign.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleBanSign.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleCheck.imageset/CircleCheck.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleCheck.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleDotted.imageset/CircleDotted.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleDotted.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleInfo.imageset/CircleInfo.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleInfo.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleMinus.imageset/CircleMinus.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleMinus.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CirclePerson.imageset/CirclePerson.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CirclePerson.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CirclePlus.imageset/CirclePlus.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CirclePlus.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleX.imageset/CircleX.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleX.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleXFilled.imageset/CircleXFilled.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CircleXFilled.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Clipboard.imageset/Clipboard.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Clipboard.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Clock.imageset/Clock.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Clock.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Coins.imageset/Coins.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Coins.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CoinsAdd.imageset/CoinsAdd.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CoinsAdd.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CreditCard1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CreditCard1.imageset/CreditCard1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CrossMedium.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/CrossMedium.imageset/CrossMedium.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Crown.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Crown.imageset/Crown.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/DotGrid1x3Horizontal.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/DotGrid1x3Horizontal.imageset/DotGrid1x3Horizontal.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/DotGrid1x3Vertical.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/DotGrid1x3Vertical.imageset/DotGrid1x3Vertical.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Email1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Email1.imageset/Email1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/EmojiAddReaction.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/EmojiAddReaction.imageset/EmojiAddReaction.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ExclamationCircle.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ExclamationCircle.imageset/ExclamationCircle.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ExclamationTriangle.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ExclamationTriangle.imageset/ExclamationTriangle.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FaceId.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FaceId.imageset/FaceId.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileBend.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileBend.imageset/FileBend.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileDownload.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileDownload.imageset/FileDownload.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileText.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FileText.imageset/FileText.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FingerPrint1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/FingerPrint1.imageset/FingerPrint1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Flag1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Flag1.imageset/Flag1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Folder1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Folder1.imageset/Folder1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Gift1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Gift1.imageset/Gift1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Globe.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Globe.imageset/Globe.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Grid.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Grid.imageset/Grid.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Group1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Group1.imageset/Group1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Group3.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Group3.imageset/Group3.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Hand5Finger.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Hand5Finger.imageset/Hand5Finger.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/History.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/History.imageset/History.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Images1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Images1.imageset/Images1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Images3.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Images3.imageset/Images3.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Invite.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Invite.imageset/Invite.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Key1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Key1.imageset/Key1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/KikCode.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/KikCode.imageset/KikCode.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/KikCodeFilled.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/KikCodeFilled.imageset/KikCodeFilled.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/LightningBolt.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/LightningBolt.imageset/LightningBolt.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/LightningBoltOff.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/LightningBoltOff.imageset/LightningBoltOff.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Lock.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Lock.imageset/Lock.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/MagnifyingGlass.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/MagnifyingGlass.imageset/MagnifyingGlass.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Maintenance.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Maintenance.imageset/Maintenance.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PaperPlaneTopRight.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PaperPlaneTopRight.imageset/PaperPlaneTopRight.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Pencil2.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Pencil2.imageset/Pencil2.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/People.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/People.imageset/People.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleCircle.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleCircle.imageset/PeopleCircle.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleCircleFilled.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleCircleFilled.imageset/PeopleCircleFilled.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleGear.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleGear.imageset/PeopleGear.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleIdCard.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PeopleIdCard.imageset/PeopleIdCard.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Phone.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Phone.imageset/Phone.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PlusLarge.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/PlusLarge.imageset/PlusLarge.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/QrCode.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/QrCode.imageset/QrCode.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/QuestionmarkCircle.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/QuestionmarkCircle.imageset/QuestionmarkCircle.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Send.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Send.imageset/Send.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SettingsGear2.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SettingsGear2.imageset/SettingsGear2.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SettingsSliderThree.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SettingsSliderThree.imageset/SettingsSliderThree.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ShareOs.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/ShareOs.imageset/ShareOs.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SquareBehindSquare1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SquareBehindSquare1.imageset/SquareBehindSquare1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SquareX.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/SquareX.imageset/SquareX.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/TextSize.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/TextSize.imageset/TextSize.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/TrashCan.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/TrashCan.imageset/TrashCan.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Trophy.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Trophy.imageset/Trophy.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/User.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/User.imageset/User.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/UserAdd.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/UserAdd.imageset/UserAdd.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/VolumeOff.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/VolumeOff.imageset/VolumeOff.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet1.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet1.imageset/Wallet1.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet5.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet5.imageset/Wallet5.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet5Filled.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/central/Wallet5Filled.imageset/Wallet5Filled.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatStyle.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Theme/Image+Icon.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Tray/TrayAction.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Tray/TrayAmountEntry.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Tray/TrayAmountMetrics.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Tray/TrayCloseButton.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Tray/TrayFlow.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Tray/TrayIcon.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Tray/TrayMetrics.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Tray/TrayMotion.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Tray/TrayOptionRow.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Tray/TrayOptionTile.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Tray/TrayPage.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Tray/TrayPresentation.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Tray/TraySandbox.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Tray/TraySheetDriver.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Tray/TrayStack.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Tray/TrayStyle.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Views/Containers/ListRow.swift diff --git a/Flipcash/Core/AppDelegate.swift b/Flipcash/Core/AppDelegate.swift index f96792248..a5775913a 100644 --- a/Flipcash/Core/AppDelegate.swift +++ b/Flipcash/Core/AppDelegate.swift @@ -324,12 +324,14 @@ private extension AppDelegate { func setupAppearance() { let largeAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.appDisplayLarge, + // Apple's own large title: Large Title, 34 pt bold, following Dynamic Type. + .font: UIFont.systemFont(ofSize: UIFont.preferredFont(forTextStyle: .largeTitle).pointSize, weight: .bold), .foregroundColor: UIColor(.textMain), ] let titleAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.appTitle, + // Apple's own inline title: Headline, 17 pt semibold, following Dynamic Type. + .font: UIFont.preferredFont(forTextStyle: .headline), .foregroundColor: UIColor(.textMain), ] @@ -342,6 +344,7 @@ private extension AppDelegate { buttonAppearance.normal.titleTextAttributes = buttonAttributes let bar = UINavigationBar.appearance() + let backArrow = Self.backIndicator() bar.largeTitleTextAttributes = largeAttributes bar.titleTextAttributes = titleAttributes @@ -358,6 +361,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 +375,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 +386,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..958213cfe 100644 --- a/Flipcash/Core/FlipcashApp.swift +++ b/Flipcash/Core/FlipcashApp.swift @@ -22,11 +22,15 @@ struct FlipcashApp: App { WindowGroup { #if DEBUG if MotionSandbox.isRequested { - MotionSandbox() + MotionSandbox().tunerFloating() // LOCAL-TUNER + } else if DialogSandbox.isRequested { // LOCAL-TUNER + DialogSandbox().tunerFloating() // LOCAL-TUNER + } else if TraySandbox.isRequested { // LOCAL-TUNER + TraySandbox().tunerFloating() // LOCAL-TUNER } else if let benchmark = ScrollBenchmark.requested { benchmark } else { - mainScene + mainScene.tunerFloating(groups: ["Chat list", "Chat list Row", "Chat list Title", "Chat list Preview", "Chat list Date", "Chat list Unread", "Chat v2 Date", "Tray Layout", "Tray Type", "Tray Destructive", "Tray Rows", "Tray Amount", "Tray Mute", "Tray E2EE", "Tray Fill", "Tray Motion", "Tray Label", "Tray Behavior"]) // LOCAL-TUNER } #else mainScene @@ -183,3 +187,51 @@ private extension View { modifier(ScenePhaseModifier(appDelegate: appDelegate)) } } + +#if DEBUG // LOCAL-TUNER +/// Real `DialogItem`s through the real `.dialog(item:)` seam, with no sign-in (`--dialog-sandbox`, // LOCAL-TUNER +/// `--dialog-sandbox-open `). Actions do nothing. // LOCAL-TUNER +private struct DialogSandbox: View { // LOCAL-TUNER + + static var isRequested: Bool { ProcessInfo.processInfo.arguments.contains("--dialog-sandbox") } // LOCAL-TUNER + + @State private var dialogItem: DialogItem? // LOCAL-TUNER + + private let trustedWebsites = TrustedWebsites(defaults: UserDefaults(suiteName: "dialog-sandbox")!) // LOCAL-TUNER + + private var items: [(String, () -> DialogItem)] { // LOCAL-TUNER + [ // LOCAL-TUNER + ("Log out (alert, destructive)", { // LOCAL-TUNER + .alert(title: "Are You Sure You Want To Log Out?", subtitle: "You can get into this account using your Access Key") { // LOCAL-TUNER + DialogAction.destructive("Log Out") {} // LOCAL-TUNER + DialogAction.cancel() // LOCAL-TUNER + } // LOCAL-TUNER + }), // LOCAL-TUNER + ("Leaving Flipcash (info, checkbox)", { .leavingFlipcash(host: "example.com", trustedWebsites: trustedWebsites) {} }), // LOCAL-TUNER + ("Error (tracked, default OK)", { .error(title: "Something Went Wrong", subtitle: "Please try again later") }), // LOCAL-TUNER + ("Success (not dismissable)", { .success(title: "Report Sent", subtitle: "Thanks. We'll take a look") }), // LOCAL-TUNER + ] // LOCAL-TUNER + } // LOCAL-TUNER + + var body: some View { // LOCAL-TUNER + VStack(spacing: 12) { // LOCAL-TUNER + ForEach(items.indices, id: \.self) { index in // LOCAL-TUNER + Button(items[index].0) { dialogItem = items[index].1() } // LOCAL-TUNER + .buttonStyle(.filled20Compact) // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + .padding(20) // LOCAL-TUNER + .frame(maxWidth: .infinity, maxHeight: .infinity) // LOCAL-TUNER + .background(Color.backgroundMain) // LOCAL-TUNER + .dialog(item: $dialogItem) // LOCAL-TUNER + .preferredColorScheme(.dark) // LOCAL-TUNER + .task { // LOCAL-TUNER + let arguments = ProcessInfo.processInfo.arguments // LOCAL-TUNER + guard let i = arguments.firstIndex(of: "--dialog-sandbox-open"), i + 1 < arguments.count, // LOCAL-TUNER + let index = Int(arguments[i + 1]), items.indices.contains(index) else { return } // LOCAL-TUNER + try? await Task.sleep(for: .seconds(0.5)) // LOCAL-TUNER + dialogItem = items[index].1() // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER +} // LOCAL-TUNER +#endif // LOCAL-TUNER diff --git a/Flipcash/Core/Screens/Conversation/AttachMenu.swift b/Flipcash/Core/Screens/Conversation/AttachMenu.swift index 65fb669a8..a167f2ec1 100644 --- a/Flipcash/Core/Screens/Conversation/AttachMenu.swift +++ b/Flipcash/Core/Screens/Conversation/AttachMenu.swift @@ -59,11 +59,12 @@ struct AttachMenu: View { } panel.animate { $0.toggle() } } label: { - Image(systemName: SystemSymbol.plus.rawValue) - .font(.default(size: 17, weight: .semibold)) - .foregroundStyle(Color.textMain) + Image.icon(.plusLarge) + .iconSize(ComposerTuning.plusIconSize) // LOCAL-TUNER + .foregroundStyle(ComposerTuning.plusIconColor) // LOCAL-TUNER .frame(width: BarMetrics.accessorySize, height: BarMetrics.accessorySize) - .background(AttachSurfaceLayout.plusFill, in: Circle()) + .background(ComposerTuning.plusStyle == .filled ? ComposerTuning.plusFill : .clear, in: Circle()) // LOCAL-TUNER + .overlay(Circle().strokeBorder(ComposerTuning.plusStyle == .outlined ? ComposerTuning.plusFill : .clear, lineWidth: 1.5)) // LOCAL-TUNER .contentShape(Circle()) } .buttonStyle(.plain) @@ -149,7 +150,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 +179,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 +203,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..4cf6daa73 100644 --- a/Flipcash/Core/Screens/Conversation/AttachSurface.swift +++ b/Flipcash/Core/Screens/Conversation/AttachSurface.swift @@ -247,9 +247,9 @@ 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)) - .foregroundStyle(Color.textMain) + Image.icon(.plusLarge) + .iconSize(ComposerTuning.plusIconSize) // LOCAL-TUNER + .foregroundStyle(ComposerTuning.plusIconColor) // LOCAL-TUNER .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..e9e9b278d 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,9 @@ 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) .padding(3) .contentShape(Rectangle()) } diff --git a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift index 2b0034e58..04f92af5b 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift @@ -73,18 +73,10 @@ 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) // 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 9c28850fd..9491ce7de 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -183,8 +183,8 @@ struct JumpToBottomOverlay: View { // 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 + let inset = BarMetrics.trailingControlCentreInset(isCompact: true) // LOCAL-TUNER: same column resting and typing + - JumpToBottomButtonView.diameter / 2 + ComposerTuning.jumpNudge // LOCAL-TUNER JumpToBottomButtonView(unreadCount: jump.unreadCount, action: jump.onTap) .frame(width: JumpToBottomButtonView.diameter, height: JumpToBottomButtonView.diameter) // Opacity only: a glass effect under a scale effect drew a shrunken copy of the screen. @@ -215,34 +215,34 @@ private let replySpring = ChatMotion.replySurface.animation /// the field the controls are field-sized, not standard-button-sized. enum BarMetrics { nonisolated static let fieldMinHeight: CGFloat = 34 - nonisolated static let fieldVerticalPadding: CGFloat = 8 + nonisolated static var fieldVerticalPadding: CGFloat { Tuner.value("Chat v2 Composer/Field padding Y", default: 8, in: 0...20, unit: .points) } // LOCAL-TUNER /// The field's padding on every side: where `+` stands in from the field's leading edge, and so /// how far the open attach menu reaches back past `+` to line up with the field. - nonisolated static let fieldPadding: CGFloat = 8 + nonisolated static var fieldPadding: CGFloat { Tuner.value("Chat v2 Composer/Field padding X", default: 8, in: 0...20, unit: .points) } // LOCAL-TUNER static let cornerRadius: CGFloat = 14 /// The composer field's corner, rounder than the bar's other controls. - nonisolated static let fieldCornerRadius: CGFloat = fieldPadding + accessorySize / 2 + nonisolated static var fieldCornerRadius: CGFloat { fieldPadding + accessorySize / 2 } // LOCAL-TUNER /// The height of every bar control: a single-line field plus its padding, and the height the /// Send Cash button morphs at while there is a composer beside it. - nonisolated static let contentHeight: CGFloat = fieldMinHeight + fieldVerticalPadding * 2 + nonisolated static var contentHeight: CGFloat { fieldMinHeight + fieldVerticalPadding * 2 } // LOCAL-TUNER /// The diameter of the controls beside the text: `+`, the Send Cash button and the send button. - nonisolated static let accessorySize: CGFloat = 34 + nonisolated static var accessorySize: CGFloat { Tuner.value("Chat v2 Composer/Button size", default: 34, in: 24...48, unit: .points) } // LOCAL-TUNER /// The bar's own margin around its controls, above and below. - static let contentPadding: CGFloat = 8 + static var contentPadding: CGFloat { Tuner.value("Chat v2 Composer/Bar padding Y", default: 8, in: 0...24, unit: .points) } // LOCAL-TUNER /// The margin between the bar's controls and the screen's sides while the keyboard is up. - static let edgeInset: CGFloat = 12 + static var edgeInset: CGFloat { Tuner.value("Chat v2 Composer/Side inset typing", default: 12, in: 0...40, unit: .points) } // LOCAL-TUNER /// How far into the home indicator's safe area the compact bar rests while the keyboard is down. - static let compactDrop: CGFloat = 8 + static var compactDrop: CGFloat { Tuner.value("Chat v2 Composer/Rest drop", default: 8, in: 0...30, unit: .points) } // LOCAL-TUNER /// The margin between the compact bar's controls and the screen's sides. - static let compactInset: CGFloat = 32 + static var compactInset: CGFloat { Tuner.value("Chat v2 Composer/Side inset resting", default: 32, in: 0...48, unit: .points) } // LOCAL-TUNER /// The gap between the field and the `$` beside it, the same compact and composing. - nonisolated static let controlSpacing: CGFloat = 10 + nonisolated static var controlSpacing: CGFloat { Tuner.value("Chat v2 Composer/Field to $ gap", default: 12, in: 0...24, unit: .points) } // LOCAL-TUNER /// 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 + static var jumpButtonGap: CGFloat { Tuner.value("Chat v2 Jump/Gap above $", default: 23.6, in: 0...60, unit: .points) } // LOCAL-TUNER /// 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 + static var jumpButtonGapAboveBar: CGFloat { jumpButtonGap - contentPadding } // LOCAL-TUNER /// The margin between the bar's controls and the screen's sides: ``compactInset`` while the bar /// rests compact, ``edgeInset`` while composing. @@ -259,7 +259,7 @@ enum BarMetrics { /// How far a toast comes back down into the compact bar's measured height, so it rests 8pt above /// the controls: the bar's top margin, its drop into the safe area, and the 4pt the toast host's /// 12pt padding exceeds that gap. - static let toastOverlap: CGFloat = contentPadding + compactDrop + 4 + static var toastOverlap: CGFloat { contentPadding + compactDrop + 4 } // LOCAL-TUNER } /// What stands outside the message field, beside it. @@ -391,7 +391,7 @@ struct ConversationBottomBar: View { .combined(with: .scale(scale: 0.4)) /// The gap between the field and the control beside it: cancel-edit on its left, `$` on its right. The composer's glass joins across no /// more than this, so `$` stays bridged to the field while it travels and pinches off at rest. - static let leadingSpacing: CGFloat = BarMetrics.controlSpacing + static var leadingSpacing: CGFloat { BarMetrics.controlSpacing } // LOCAL-TUNER /// How close the composer's glass shapes come before they flow together: under the resting gap, /// so `$` is bridged to the field while it travels and stands clear of it at rest. private static let glassJoinDistance: CGFloat = 8 @@ -1228,7 +1228,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(ComposerTuning.font) // LOCAL-TUNER .foregroundStyle(Color.textMain) .tint(.white) .lineLimit(1...7) @@ -1271,7 +1271,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(ComposerTuning.font) // LOCAL-TUNER .lineLimit(1) .fixedSize() .hidden() @@ -1368,14 +1368,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") @@ -1411,8 +1410,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() { @@ -1512,8 +1511,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 @@ -1552,3 +1551,21 @@ struct ComposerImageDropDelegate: DropDelegate { return true } } + +// LOCAL-TUNER: composer and jump-button controls that aren't plain BarMetrics numbers. +enum ComposerTuning { // LOCAL-TUNER + static var textSize: CGFloat { Tuner.value("Chat v2 Composer/Text size", default: 17, in: 13...22, unit: .points) } // LOCAL-TUNER + static var textWeight: ChatTitleWeight { Tuner.value("Chat v2 Composer/Text weight", default: ChatTitleWeight.regular) } // LOCAL-TUNER + static var font: Font { .default(size: textSize, weight: textWeight.weight) } // LOCAL-TUNER + static var plusStyle: ComposerPlusStyle { Tuner.value("Chat v2 Composer/Plus style", default: ComposerPlusStyle.filled) } // LOCAL-TUNER + static var plusFill: Color { Tuner.value("Chat v2 Composer/Plus fill", default: Color.white.opacity(0.08)) } // LOCAL-TUNER + static var plusIconSize: CGFloat { Tuner.value("Chat v2 Composer/Plus icon size", default: 22, in: 12...30, unit: .points) } // LOCAL-TUNER + static var plusIconColor: Color { Tuner.value("Chat v2 Composer/Plus icon color", default: Color.textMain) } // LOCAL-TUNER + /// Moves the jump button sideways from its column: positive goes left. // LOCAL-TUNER + static var jumpNudge: CGFloat { Tuner.value("Chat v2 Jump/Horizontal nudge", default: -32, in: -40...40, unit: .points) } // LOCAL-TUNER +} // LOCAL-TUNER + +enum ComposerPlusStyle: String, TunableEnum { // LOCAL-TUNER + case filled, outlined, bare // LOCAL-TUNER +} // LOCAL-TUNER + diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index ac22eb093..9def1e2ae 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -678,7 +678,8 @@ struct ConversationScreen: View { Button { composer.endEditing() } label: { - Image(systemName: "chevron.backward") + Image.icon(.arrowLeft) + .iconSize(22) .foregroundStyle(Color.textMain) } .accessibilityLabel("Stop editing") @@ -803,7 +804,7 @@ struct ConversationScreen: View { ContactCardView(card: card) .ignoresSafeArea() } - .sheet(isPresented: $isShowingEncryptionInfo) { + .tray(isPresented: $isShowingEncryptionInfo) { E2eeLearnMoreSheet(kind: .dm, isPresented: $isShowingEncryptionInfo) } .fullScreenCover(item: $messageShare) { share in @@ -1606,8 +1607,22 @@ private struct ConversationTitleItem: View { } } +enum ChatAvatarMode: String, TunableEnum { // LOCAL-TUNER + case real, initials, link // LOCAL-TUNER +} // LOCAL-TUNER + +enum ChatTitleWeight: String, TunableEnum { // LOCAL-TUNER + case regular, medium, semibold // LOCAL-TUNER + var weight: Font.Weight { switch self { case .regular: .regular; case .medium: .medium; case .semibold: .semibold } } // LOCAL-TUNER +} // LOCAL-TUNER + private struct ConversationTitleLabel: View { + // LOCAL-TUNER: first letters of the first two words, for the initials preview. + private static func initials(_ name: String) -> String { // LOCAL-TUNER + name.split(separator: " ").prefix(2).compactMap(\.first).map(String.init).joined().uppercased() // LOCAL-TUNER + } // LOCAL-TUNER + let title: String let subtitle: String? let contact: ResolvedContact? @@ -1617,30 +1632,50 @@ private struct ConversationTitleLabel: View { let width: CGFloat var mute: ConversationMuteState? = nil + @Tunable("Chat v2 Avatar/Mode") var avatarMode = ChatAvatarMode.real // LOCAL-TUNER + @Tunable("Chat v2 Avatar/Image link") var avatarURL = "https://picsum.photos/seed/flipcash/200" // LOCAL-TUNER + @Tunable("Chat v2 Avatar/Initials top") var initialsTop = Color(red: 0.25, green: 0.25, blue: 0.25) // LOCAL-TUNER + @Tunable("Chat v2 Avatar/Initials bottom") var initialsBottom = Color(red: 0.13, green: 0.13, blue: 0.13) // LOCAL-TUNER + @Tunable("Chat v2 Avatar/Initials color") var initialsColor = Color.white // LOCAL-TUNER + @Tunable("Chat v2 Avatar/Initials size", in: 0.2...0.6) var initialsScale: CGFloat = 0.36 // LOCAL-TUNER + @Tunable("Chat v2 Title/Avatar size", in: 28...56, unit: .points) var avatarSize: CGFloat = 44 // LOCAL-TUNER + @Tunable("Chat v2 Title/Avatar to name", in: 4...20, unit: .points) var avatarSpacing: CGFloat = 12 // LOCAL-TUNER + @Tunable("Chat v2 Title/Name size", in: 13...22, unit: .points) var nameSize: CGFloat = 17 // LOCAL-TUNER + @Tunable("Chat v2 Title/Name weight") var nameWeight = ChatTitleWeight.semibold // LOCAL-TUNER + var body: some View { - HStack(spacing: 12) { + HStack(spacing: avatarSpacing) { // LOCAL-TUNER ContactAvatarView( id: contact?.contactId ?? conversationID?.description ?? title, displayName: title, - imageData: imageData, - blurhash: blurhash, - size: 44 + imageData: avatarMode == .real ? imageData : nil, // LOCAL-TUNER + blurhash: avatarMode == .real ? blurhash : nil, // LOCAL-TUNER + size: avatarSize // LOCAL-TUNER ) + .overlay { // LOCAL-TUNER + if avatarMode == .initials { // LOCAL-TUNER + Circle().fill(LinearGradient(colors: [initialsTop, initialsBottom], startPoint: .top, endPoint: .bottom)) // LOCAL-TUNER + .overlay(Text(Self.initials(title)).font(.default(size: avatarSize * initialsScale, weight: .semibold)).foregroundStyle(initialsColor)) // LOCAL-TUNER + } else if avatarMode == .link, let url = URL(string: avatarURL) { // LOCAL-TUNER + AsyncImage(url: url) { $0.resizable().scaledToFill() } placeholder: { Color.clear } // LOCAL-TUNER + .frame(width: avatarSize, height: avatarSize).clipShape(Circle()) // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER .accessibilityHidden(true) // The 44pt avatar already sets the bar's height, so the second line costs nothing and a // titled-only DM keeps the layout it has (nodes 10125:19191-19194). VStack(alignment: .leading, spacing: 0) { HStack(spacing: 4) { Text(title) - .font(.appBarButton) + .font(.default(size: nameSize, weight: nameWeight.weight)) // LOCAL-TUNER .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/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 434ac1bc3..286ac01aa 100644 --- a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift @@ -200,7 +200,8 @@ struct CurrencyCreationWizardScreen: View { .toolbar { ToolbarItem(placement: .topBarLeading) { Button(action: goBack) { - Image(systemName: "chevron.backward") + Image.icon(.arrowLeft) + .iconSize(22) .foregroundStyle(Color.textMain) } } diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift index 01adb2d98..96b5c03ea 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift @@ -289,16 +289,10 @@ 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: 12) { if isOwned { - actionTile("Cash", icon: .asset("IconBanknote"), action: onGive) + actionTile("Cash", icon: .banknote1, action: onGive) if isUSDF { // Dollars is what the other currencies are bought with, so // there is nothing to buy more of — it withdraws instead. @@ -306,31 +300,31 @@ struct CurrencyInfoContentV2: View { // another holding to buy into. actionTile( "Convert", - icon: .asset("IconArrowBottomTop"), + icon: .arrowBottomTop, isEnabled: viewModel.canConvert, action: onConvert ) - actionTile("Withdraw", icon: .asset("IconArrowUp"), action: onWithdraw) + actionTile("Withdraw", icon: .arrowUp, action: onWithdraw) } else { - actionTile("Buy More", icon: .asset("IconArrowDown"), action: onBuy) - actionTile("Convert", icon: .asset("IconArrowBottomTop"), action: onConvert) + actionTile("Buy More", icon: .arrowDown, action: onBuy) + actionTile("Convert", icon: .arrowBottomTop, action: onConvert) } } else { - actionTile("Buy In", icon: .asset("IconArrowDown"), action: onBuy) + actionTile("Buy In", icon: .arrowDown, action: onBuy) } } } private func actionTile( _ title: String, - icon: TileIcon, + icon: Icon, isEnabled: Bool = true, action: @escaping () -> Void ) -> some View { Button(action: action) { VStack(spacing: 12) { - tileIcon(icon) - .frame(height: 28) + Image.icon(icon) + .iconSize(28) Text(title) .font(.appTextSmall) } @@ -345,21 +339,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: 22, weight: .regular)) - case .asset(let name): - // Figma exports these glyphs at 28×28 (template-rendered, so they - // tint with the tile's foreground). - Image(name) - .resizable() - .scaledToFit() - .frame(width: 28, height: 28) - } - } - // MARK: - Recent private var recentSection: some View { 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 312dec741..18d4d9d42 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -336,7 +336,8 @@ private struct CurrencyInfoScreenContent: View { if !isUSDF { ToolbarItem(placement: .topBarTrailing) { ShareLink(item: URL(string: "https://app.flipcash.com/token/\(mint.base58)")!) { - Image(systemName: "square.and.arrow.up") + Image.icon(.shareOs) + .iconSize(22) } .simultaneousGesture(TapGesture().onEnded { Analytics.buttonTapped(name: .shareTokenInfo) @@ -398,8 +399,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. @@ -417,8 +418,8 @@ private struct CurrencyInfoScreenContent: View { if !isUSDF { 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: 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 95134a527..f82481629 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoSocialLinksSection.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoSocialLinksSection.swift @@ -19,7 +19,7 @@ struct CurrencyInfoSocialLinksSection: View { switch socialLink { case .website(let url): Link("Website", destination: url) - .buttonStyle(.icon(.globus)) + .buttonStyle(.icon(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..5ba8c907d 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,21 @@ 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) } } else { - CurrencyRowContent(description: description, viewModel: viewModel) + CurrencyRowContent(description: description, viewModel: viewModel, isLast: isLast) } } } @@ -106,30 +120,25 @@ 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) .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 b83d13c2e..682ca0632 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`` and ``postOnboarding``. 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 @@ -27,24 +27,14 @@ enum HomeTab: Int, CaseIterable, Identifiable, Hashable { /// The tab selected when onboarding finishes creating a new account. static let postOnboarding: HomeTab = .wallet - /// 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 { + let isSelected = isSelected && TabBarItemStyle.fillsSelected // LOCAL-TUNER 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 5b3214e91..b838c6c03 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -38,27 +38,11 @@ struct HomeTabView: View { _selection = State(initialValue: initialTab) _hasOpenedChat = State(initialValue: initialTab == .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 } @@ -105,6 +89,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) @@ -254,7 +260,7 @@ struct HomeTabView: View { // Selected-tab highlight over a lightly tinted glass bar — echoes the old // pill's white indicator on a translucent capsule. .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 @@ -275,22 +281,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 @@ -542,28 +551,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/NewUserTutorial.swift b/Flipcash/Core/Screens/Main/Home/NewUserTutorial.swift index b867fae2d..88cc496ae 100644 --- a/Flipcash/Core/Screens/Main/Home/NewUserTutorial.swift +++ b/Flipcash/Core/Screens/Main/Home/NewUserTutorial.swift @@ -36,8 +36,8 @@ nonisolated enum TutorialItem: TutorialItemPresentable { var icon: Image { switch self { - case .addMoney: return Image(systemName: "plus.circle") - case .scanTipCard: return Image("NavTipCard") + case .addMoney: return Image.icon(.circlePlus) + case .scanTipCard: return Image.icon(HomeTab.scan.icon(isSelected: false)) } } } 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..b545db0b1 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 var glyphSize: CGFloat { TabBarItemStyle.glyphSize } // LOCAL-TUNER /// How far the bubble reaches past the glyph's top-right corner, matching /// the legacy pill's badge (node 8966:1846). @@ -28,28 +28,30 @@ enum TabBarBadgedIcon { /// and each call would otherwise run two `ImageRenderer` passes. static func itemImages(for tab: HomeTab, count: Int) -> TabBarProfilePhoto.ItemImages? { guard count > 0 else { return nil } - if let memoized, memoized.tab == tab, memoized.count == count { return memoized.images } + if let memoized, memoized.tab == tab, memoized.count == count, memoized.size == glyphSize, memoized.opacity == TabBarItemStyle.unselectedOpacity, memoized.fills == TabBarItemStyle.fillsSelected { return memoized.images } // LOCAL-TUNER guard let normal = image(of: icon(for: tab, count: count, isSelected: false)), let selected = image(of: icon(for: tab, count: count, isSelected: true)) else { return nil } let images = TabBarProfilePhoto.ItemImages(normal: normal, selected: selected) - memoized = (tab, count, images) + memoized = (tab, count, glyphSize, TabBarItemStyle.unselectedOpacity, TabBarItemStyle.fillsSelected, images) // LOCAL-TUNER return images } - private static var memoized: (tab: HomeTab, count: Int, images: TabBarProfilePhoto.ItemImages)? + private static var memoized: (tab: HomeTab, count: Int, size: CGFloat, opacity: Double, fills: Bool, images: TabBarProfilePhoto.ItemImages)? // LOCAL-TUNER /// 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..3220f2d50 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/TabBarItemStyle.swift @@ -0,0 +1,108 @@ +// +// 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, unless +/// ``showsNativeTitles``, its title. The bar is left only the selection lens. +@MainActor +enum TabBarItemStyle { + + enum TitleWeight: TunableEnum { case regular, medium, semibold } // LOCAL-TUNER + + /// The point size every tab glyph is drawn at, badged or not. + static var glyphSize: CGFloat { Tuner.shared.value("Tab bar/Icon size", default: CGFloat(28), in: 16...40, unit: .points) } // LOCAL-TUNER + /// Moves the glyph and its title down (positive) or up within the bar. + static var verticalOffset: CGFloat { Tuner.shared.value("Tab bar/Vertical offset", default: CGFloat(0), in: -12...12, unit: .points) } // LOCAL-TUNER + /// The opacity of the glyph and title of the tabs that aren't selected. + static var unselectedOpacity: Double { Tuner.shared.value("Tab bar/Unselected opacity", default: 0.5, in: 0...1) } // LOCAL-TUNER + /// Whether the selected tab swaps to its filled glyph. + static var fillsSelected: Bool { Tuner.shared.value("Tab bar/Fill selected icon", default: true) } // LOCAL-TUNER + /// Whether the tabs carry titles under their glyphs. + static var showsTitles: Bool { Tuner.shared.value("Tab bar/Show titles", default: true) } // LOCAL-TUNER + /// Whether the titles are the system's own, which can't be dimmed, rather than drawn into the image. + static var usesSystemTitles: Bool { Tuner.shared.value("Tab bar/System titles", default: false) } // LOCAL-TUNER + /// The drawn title's point size. + static var titleSize: CGFloat { Tuner.shared.value("Tab bar/Title size", default: CGFloat(10), in: 8...16, unit: .points) } // LOCAL-TUNER + /// The drawn title's weight. + static var titleWeight: TitleWeight { Tuner.shared.value("Tab bar/Title weight", default: TitleWeight.semibold) } // LOCAL-TUNER + /// The space between the glyph and the drawn title. + static var titleGap: CGFloat { Tuner.shared.value("Tab bar/Title gap", default: CGFloat(2), in: 0...16, unit: .points) } // LOCAL-TUNER + /// The opacity of the bar's tint over its Liquid Glass. + static var backgroundOpacity: Double { Tuner.shared.value("Tab bar/Background opacity", default: 0.5, in: 0...1) } // LOCAL-TUNER + + /// Whether the bar shows the system's titles under the item images. + static var showsNativeTitles: Bool { showsTitles && usesSystemTitles } + + /// 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)) + } + } + + /// Stacks the glyph 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? = showsTitles && !usesSystemTitles + ? NSAttributedString(string: title, attributes: [.font: titleFont, .foregroundColor: textColor]) + : nil + + // The glyph's alignment rect leaves out a badge's overhang, so the pair 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 = text.map { CGSize(width: ceil($0.size().width), height: ceil($0.size().height)) } ?? .zero + + let width = max(glyph.size.width, textSize.width) + let contentHeight = glyph.size.height + (text == nil ? 0 : 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) + 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 + insets.top + glyphHeight + titleGap + )) + } + .withRenderingMode(.alwaysOriginal) + } + + private static var titleFont: UIFont { + switch titleWeight { + case .regular: UIFont.default(size: titleSize, weight: .regular) + case .medium: UIFont.default(size: titleSize, weight: .medium) + case .semibold: 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 67c70bb6e..9d15db1c2 100644 --- a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift @@ -244,11 +244,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 412f070da..c96050e02 100644 --- a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift @@ -336,8 +336,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 @@ -352,8 +352,8 @@ private struct WalletScreenContent: View { 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()) @@ -589,45 +589,36 @@ private struct WalletScreenContent: View { private var walletTiles: some View { VStack(spacing: 12) { 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 Money", identifier: "wallet-tile-add-money") { + walletTile(icon: .arrowWallDown, title: "Add Money", 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 Money", identifier: "wallet-tile-withdraw-money") { + walletTile(icon: .arrowUpWall, title: "Withdraw Money", identifier: "wallet-tile-withdraw-money") { router.push(.withdrawCurrency(nil)) } } HStack(spacing: 12) { - walletTile(icon: .symbol("globe"), title: "Discover Currencies", identifier: "wallet-tile-discover-currencies") { + walletTile(icon: .globe, title: "Discover Currencies", identifier: "wallet-tile-discover-currencies") { router.push(.discoverCurrencies) } - walletTile(icon: .asset(.coinsAdd), title: "Create a Currency", identifier: "wallet-tile-create-currency") { + walletTile(icon: .coinsAdd, title: "Create a Currency", identifier: "wallet-tile-create-currency") { router.push(.currencyCreationSummary) } } } } - /// 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: several of these titles also appear on /// the new-user tutorial rows above, where a completed milestone renders /// disabled, so matching on the label alone can resolve to an untappable row. - 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) @@ -646,22 +637,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. diff --git a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift index 65f40bc9b..d84e38a41 100644 --- a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift +++ b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift @@ -261,7 +261,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/New Group/ShareToChatsSheet.swift b/Flipcash/Core/Screens/Main/Tips/New Group/ShareToChatsSheet.swift index 15c7c38e4..53933bec4 100644 --- a/Flipcash/Core/Screens/Main/Tips/New Group/ShareToChatsSheet.swift +++ b/Flipcash/Core/Screens/Main/Tips/New Group/ShareToChatsSheet.swift @@ -301,7 +301,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() diff --git a/Flipcash/Core/Screens/Main/Tips/NewChatScreen.swift b/Flipcash/Core/Screens/Main/Tips/NewChatScreen.swift index 003604f6b..7dae46371 100644 --- a/Flipcash/Core/Screens/Main/Tips/NewChatScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/NewChatScreen.swift @@ -33,7 +33,7 @@ struct NewChatScreen: View { } ChatActionRow( - icon: .asset(.lock), + icon: .icon(.lock), title: "Create a Private Group", accessibilityIdentifier: "new-chat-private-group" ) { diff --git a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift index 1c6514530..d6af8d6f3 100644 --- a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift +++ b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift @@ -68,22 +68,23 @@ struct RecipientRowBody: View { @ViewBuilder let trailing: Trailing var body: some View { - HStack(spacing: 12) { + HStack(spacing: ChatListTuning.avatarGap) { // LOCAL-TUNER ContactAvatarView( id: avatarID, displayName: title, imageData: imageData, blurhash: blurhash, - size: 44 + size: ChatListTuning.avatarSize // LOCAL-TUNER ) - VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .top, spacing: 6) { + VStack(alignment: .leading, spacing: ChatListTuning.lineGap) { // LOCAL-TUNER + HStack(alignment: ChatListTuning.dateBaseline ? .firstTextBaseline : .top, spacing: 6) { // LOCAL-TUNER // 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. HStack(spacing: 0) { Text(title) - .font(.appTextMedium) - .foregroundStyle(Color.textMain) + .font(.default(size: ChatListTuning.titleSize, weight: ChatListTuning.titleWeight.weight)) // LOCAL-TUNER + .foregroundStyle(ChatListTuning.titleColor) // LOCAL-TUNER + .tracking(ChatListTuning.titleTracking) // LOCAL-TUNER .lineLimit(1) MuteBell(mute, leadingGap: 6) .fixedSize() @@ -94,10 +95,11 @@ struct RecipientRowBody: View { } } if let subtitle { - Text(subtitle) - .font(.appTextSmall) - .foregroundStyle(Color.textSecondary) - .lineLimit(1) + Text(ChatListTuning.styledPreview(subtitle)) // LOCAL-TUNER + .font(.default(size: ChatListTuning.previewSize, weight: ChatListTuning.previewWeight.weight)) // LOCAL-TUNER + .foregroundStyle(ChatListTuning.previewColor) // LOCAL-TUNER + .tracking(ChatListTuning.previewTracking) // LOCAL-TUNER + .lineLimit(ChatListTuning.previewLines) // LOCAL-TUNER .contentTransition(.opacity) .transition(.opacity) } @@ -120,7 +122,9 @@ private extension View { func recipientRowChrome(accessibilityLabel: String) -> some View { self .buttonStyle(.plain) - .listRowInsets(EdgeInsets(top: 14, leading: 20, bottom: 14, trailing: 20)) + .listRowInsets(EdgeInsets(top: ChatListTuning.rowPaddingY, leading: ChatListTuning.rowPaddingX, bottom: ChatListTuning.rowPaddingY, trailing: ChatListTuning.rowPaddingX)) // LOCAL-TUNER + .listRowSeparator(ChatListTuning.separators ? .automatic : .hidden) // LOCAL-TUNER + .alignmentGuide(.listRowSeparatorLeading) { _ in ChatListTuning.separatorInset } // LOCAL-TUNER .listRowBackground(Color.clear) .listRowSeparatorTint(.rowSeparator) .accessibilityElement(children: .ignore) @@ -143,23 +147,24 @@ struct RecipientRowAccessory: View { 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: ChatListTuning.dateGap) { // LOCAL-TUNER if isUnknown { Text("Unknown Contact") .fixedSize(horizontal: true, vertical: false) .pill() } else { - Text(timestamp.formattedRelatively(useTimeForToday: true)) - .font(.appTextSmall) - .foregroundStyle(hasUnread ? Color.unreadIndicator : Color.textSecondary) + Text(ChatListTuning.date(timestamp)) // LOCAL-TUNER + .font(.default(size: ChatListTuning.dateSize, weight: ChatListTuning.dateWeight.weight)) // LOCAL-TUNER + .tracking(ChatListTuning.dateTracking) // LOCAL-TUNER + .foregroundStyle(hasUnread && ChatListTuning.dateTintsUnread ? Color.unreadIndicator : ChatListTuning.dateColor) // LOCAL-TUNER } - if let count { + if let count, ChatListTuning.unreadStyle == .count { UnreadCountPill(count: count) - } else if hasUnread { + } else if hasUnread, ChatListTuning.unreadStyle != .none { Circle() .fill(Color.unreadIndicator) - .frame(width: 9, height: 9) - } else { + .frame(width: ChatListTuning.unreadDotSize, height: ChatListTuning.unreadDotSize) // LOCAL-TUNER + } else if ChatListTuning.showsChevron { // LOCAL-TUNER Image(systemName: "chevron.right") .resizable() .aspectRatio(contentMode: .fit) @@ -188,3 +193,86 @@ private struct UnreadCountPill: View { .background(Color.unreadIndicator, in: .capsule) } } + +// LOCAL-TUNER: every value in a chat list row, for tuning. Groups start "Chat list" so the panel can scope to them. +enum ChatListTuning { // LOCAL-TUNER + static var avatarSize: CGFloat { Tuner.value("Chat list Row/Avatar size", default: 52, in: 28...72, unit: .points) } // LOCAL-TUNER + static var avatarGap: CGFloat { Tuner.value("Chat list Row/Avatar to text", default: 12, in: 4...24, unit: .points) } // LOCAL-TUNER + static var rowPaddingY: CGFloat { Tuner.value("Chat list Row/Padding Y", default: 14, in: 4...28, unit: .points) } // LOCAL-TUNER + static var rowPaddingX: CGFloat { Tuner.value("Chat list Row/Padding X", default: 20, in: 8...32, unit: .points) } // LOCAL-TUNER + static var separators: Bool { Tuner.value("Chat list Row/Separators", default: true) } // LOCAL-TUNER + /// Where the separator starts: 0 is the row's edge; avatar + gap lines it up with the text. // LOCAL-TUNER + static var separatorInset: CGFloat { Tuner.value("Chat list Row/Separator inset", default: 56, in: 0...100, unit: .points) } // LOCAL-TUNER + static var lineGap: CGFloat { Tuner.value("Chat list Row/Title to preview", default: 3, in: 0...12, unit: .points) } // LOCAL-TUNER + + static var titleSize: CGFloat { Tuner.value("Chat list Title/Size", default: 16, in: 12...22, unit: .points) } // LOCAL-TUNER + static var titleWeight: ChatListWeight { Tuner.value("Chat list Title/Weight", default: ChatListWeight.bold) } // LOCAL-TUNER + static var titleColor: Color { Tuner.value("Chat list Title/Color", default: Color.textMain) } // LOCAL-TUNER + + static var previewSize: CGFloat { Tuner.value("Chat list Preview/Size", default: 14, in: 11...18, unit: .points) } // LOCAL-TUNER + static var previewWeight: ChatListWeight { Tuner.value("Chat list Preview/Weight", default: ChatListWeight.regular) } // LOCAL-TUNER + static var previewColor: Color { Tuner.value("Chat list Preview/Color", default: Color.textSecondary) } // LOCAL-TUNER + static var previewTracking: CGFloat { Tuner.value("Chat list Preview/Tracking", default: 0.16, in: -1...2, unit: .points) } // LOCAL-TUNER + static var titleTracking: CGFloat { Tuner.value("Chat list Title/Tracking", default: 0, in: -1...2, unit: .points) } // LOCAL-TUNER + static var dateTracking: CGFloat { Tuner.value("Chat list Date/Tracking", default: 0, in: -1...2, unit: .points) } // LOCAL-TUNER + static var previewLines: Int { Tuner.value("Chat list Preview/Lines", default: 2, in: 1...3) } // LOCAL-TUNER + static var authorWeight: ChatListWeight { Tuner.value("Chat list Preview/Author weight", default: ChatListWeight.bold) } // LOCAL-TUNER + static var authorColor: Color { Tuner.value("Chat list Preview/Author color", default: Color.textSecondary) } // LOCAL-TUNER + + static var dateStyle: ChatListDateStyle { Tuner.value("Chat list Date/Style", default: ChatListDateStyle.shortWeekday) } // LOCAL-TUNER + static var todayStyle: ChatListTodayStyle { Tuner.value("Chat list Date/Today shows", default: ChatListTodayStyle.time) } // LOCAL-TUNER + static var yesterdayWord: Bool { Tuner.value("Chat list Date/Say Yesterday", default: true) } // LOCAL-TUNER + static var dateSize: CGFloat { Tuner.value("Chat list Date/Size", default: 14, in: 10...18, unit: .points) } // LOCAL-TUNER + static var dateWeight: ChatListWeight { Tuner.value("Chat list Date/Weight", default: ChatListWeight.regular) } // LOCAL-TUNER + static var dateColor: Color { Tuner.value("Chat list Date/Color", default: Color.textSecondary) } // LOCAL-TUNER + static var dateTintsUnread: Bool { Tuner.value("Chat list Date/Blue when unread", default: true) } // LOCAL-TUNER + static var dateBaseline: Bool { Tuner.value("Chat list Date/Align to title baseline", default: false) } // LOCAL-TUNER + static var dateGap: CGFloat { Tuner.value("Chat list Date/Gap to badge", default: 6, in: 0...16, unit: .points) } // LOCAL-TUNER + + static var unreadStyle: ChatListUnreadStyle { Tuner.value("Chat list Unread/Style", default: ChatListUnreadStyle.dot) } // LOCAL-TUNER + static var unreadDotSize: CGFloat { Tuner.value("Chat list Unread/Dot size", default: 9, in: 4...16, unit: .points) } // LOCAL-TUNER + static var showsChevron: Bool { Tuner.value("Chat list Row/Show chevron", default: false) } // LOCAL-TUNER + + /// The preview with a leading "Name:" set apart in its own weight and colour. // LOCAL-TUNER + static func styledPreview(_ text: AttributedString) -> AttributedString { // LOCAL-TUNER + let plain = String(text.characters) // LOCAL-TUNER + guard let colon = plain.firstIndex(of: ":"), plain.distance(from: plain.startIndex, to: colon) <= 28, // LOCAL-TUNER + let end = text.characters.index(text.startIndex, offsetBy: plain.distance(from: plain.startIndex, to: colon) + 1, limitedBy: text.endIndex) // LOCAL-TUNER + else { return text } // LOCAL-TUNER + var styled = text // LOCAL-TUNER + styled[text.startIndex.. String { // LOCAL-TUNER + let calendar = Calendar.current // LOCAL-TUNER + if calendar.isDateInToday(date) { // LOCAL-TUNER + switch todayStyle { // LOCAL-TUNER + case .time: return date.formatted(date: .omitted, time: .shortened) // LOCAL-TUNER + case .today: return "Today" // LOCAL-TUNER + case .now: return date.formatted(.relative(presentation: .named, unitsStyle: .narrow)) // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + if yesterdayWord, calendar.isDateInYesterday(date) { return "Yesterday" } // LOCAL-TUNER + let withinWeek = date > calendar.date(byAdding: .day, value: -6, to: calendar.startOfDay(for: .now))! // LOCAL-TUNER + switch dateStyle { // LOCAL-TUNER + case .relative: return date.formattedRelatively(useTimeForToday: true) // LOCAL-TUNER + case .shortWeekday: return withinWeek ? date.formatted(.dateTime.weekday(.abbreviated)) : date.formatted(.dateTime.month(.abbreviated).day()) // LOCAL-TUNER + case .fullWeekday: return withinWeek ? date.formatted(.dateTime.weekday(.wide)) : date.formatted(.dateTime.month(.abbreviated).day()) // LOCAL-TUNER + case .monthDay: return date.formatted(.dateTime.month(.abbreviated).day()) // LOCAL-TUNER + case .numeric: return date.formatted(.dateTime.month(.defaultDigits).day()) // LOCAL-TUNER + case .ago: return date.formatted(.relative(presentation: .numeric, unitsStyle: .abbreviated)) // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER +} // LOCAL-TUNER + +enum ChatListWeight: String, TunableEnum { // LOCAL-TUNER + case regular, medium, semibold, bold // LOCAL-TUNER + var weight: Font.Weight { switch self { case .regular: .regular; case .medium: .medium; case .semibold: .semibold; case .bold: .bold } } // LOCAL-TUNER +} // LOCAL-TUNER +enum ChatListDateStyle: String, TunableEnum { case relative, shortWeekday, fullWeekday, monthDay, numeric, ago } // LOCAL-TUNER +enum ChatListTodayStyle: String, TunableEnum { case time, today, now } // LOCAL-TUNER +enum ChatListUnreadStyle: String, TunableEnum { case count, dot, none } // LOCAL-TUNER + diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index 18e9d432e..bc8c3eae1 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -10,6 +10,13 @@ import FlipcashUI /// The Chats tab: the list of tip conversations — tips sent and received. struct TipConversationsScreen: View { + @Tunable("Chat list/Title style") var titleStyle = ChatListTitleStyle.music // LOCAL-TUNER + @Tunable("Chat list/Collapse after", in: 0...120, unit: .points) var collapseDistance: CGFloat = 24 // LOCAL-TUNER + @Tunable("Chat list/Large title size", in: 24...44, unit: .points) var largeTitleSize: CGFloat = 34 // LOCAL-TUNER + @Tunable("Chat list/Large title offset", in: -80...20, unit: .points) var largeTitleOffset: CGFloat = -53 // LOCAL-TUNER + @Tunable("Chat list/Large title leading", in: 0...32, unit: .points) var largeTitleLeading: CGFloat = 16 // LOCAL-TUNER + @State private var titleCollapsed = false // LOCAL-TUNER + @Environment(ConversationController.self) private var conversationController @Environment(SessionContainer.self) private var sessionContainer @Environment(AppRouter.self) private var router @@ -101,6 +108,11 @@ struct TipConversationsScreen: View { } .listStyle(.plain) .scrollContentBackground(.hidden) + .onScrollGeometryChange(for: Bool.self) { geometry in // LOCAL-TUNER + geometry.contentOffset.y + geometry.contentInsets.top > (showsChips ? 0 : chipsHeight) + collapseDistance // LOCAL-TUNER + } action: { _, collapsed in // LOCAL-TUNER + withAnimation(.smooth(duration: 0.25)) { titleCollapsed = collapsed } // LOCAL-TUNER + } // LOCAL-TUNER .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. @@ -113,12 +125,36 @@ struct TipConversationsScreen: View { } } } - .sheet(item: $muteTarget) { target in + // LOCAL-TUNER: 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) { // LOCAL-TUNER + if titleStyle == .music { // LOCAL-TUNER + Text("Chat") // LOCAL-TUNER + .font(.system(size: largeTitleSize, weight: .bold)) // LOCAL-TUNER + .foregroundStyle(Color.textMain) // LOCAL-TUNER + .fixedSize() // LOCAL-TUNER + .padding(.leading, largeTitleLeading) // LOCAL-TUNER + .offset(y: largeTitleOffset) // LOCAL-TUNER + .opacity(titleCollapsed ? 0 : 1) // LOCAL-TUNER + .allowsHitTesting(false) // LOCAL-TUNER + .accessibilityAddTraits(.isHeader) // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + .tray(item: $muteTarget) { target in MuteChatSheet(conversationID: target.id, isPresented: isPickingMuteDuration) } .navigationTitle("Chat") - .toolbarTitleDisplayMode(.inline) + .toolbarTitleDisplayMode(titleStyle.mode) // LOCAL-TUNER .toolbar { + if titleStyle == .music { // LOCAL-TUNER + ToolbarItem(placement: .principal) { // LOCAL-TUNER + Text("Chat") // LOCAL-TUNER + .font(.headline) // LOCAL-TUNER + .foregroundStyle(Color.textMain) // LOCAL-TUNER + .opacity(titleCollapsed ? 1 : 0) // LOCAL-TUNER + .accessibilityHidden(!titleCollapsed) // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER ToolbarItem(placement: .topBarTrailing) { NewChatButton() } @@ -186,13 +222,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. @@ -207,9 +243,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. @@ -252,8 +286,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") @@ -584,3 +618,8 @@ private struct FadingChips: View { content.opacity(isShown ? 1 : fade.progress) } } + +enum ChatListTitleStyle: String, TunableEnum { // LOCAL-TUNER + case music, large, inlineLarge, inline // LOCAL-TUNER + var mode: ToolbarTitleDisplayMode { switch self { case .music, .inline: .inline; case .large: .large; case .inlineLarge: .inlineLarge } } // LOCAL-TUNER +} // LOCAL-TUNER diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index d78846d09..36c1057ad 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -167,7 +167,7 @@ struct YouScreen: View { } private var shareButton: some View { - ProfileActionCircle(image: Image.asset(.shareOS)) { + ProfileActionCircle(image: Image.icon(.shareOs)) { isSharingToChats = true } .accessibilityLabel("Share") @@ -189,7 +189,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 fe20ed97a..10a866bd0 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -238,10 +238,10 @@ struct ChatProfileScreen: View { router.push(.tipConversation(chatID)) } } - .sheet(isPresented: $isPickingMuteDuration) { + .tray(isPresented: $isPickingMuteDuration) { MuteChatSheet(conversationID: conversationID, isPresented: $isPickingMuteDuration) } - .sheet(isPresented: $isShowingE2ee) { + .tray(isPresented: $isShowingE2ee) { E2eeLearnMoreSheet(kind: isPrivate ? .privateGroup : .group, isPresented: $isShowingE2ee) } .fullScreenCover(isPresented: $isReporting) { @@ -532,7 +532,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") @@ -626,7 +627,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") diff --git a/Flipcash/Core/Screens/Profile/E2eeLearnMoreSheet.swift b/Flipcash/Core/Screens/Profile/E2eeLearnMoreSheet.swift index 3a0a24282..2fa06f5b7 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,92 +25,68 @@ 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: E2eeTrayLook.sectionGap) { + 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] { + E2eeTrayLook.showsCTA ? [TrayAction("Got It") { isPresented = false }] : [] + } + + @ViewBuilder private func section(_ heading: String, items: [String], isEncrypted: Bool) -> some View { - VStack(alignment: .leading, spacing: 12) { + let list = VStack(alignment: .leading, spacing: 12) { Text(heading) - .font(.default(size: 11, weight: .bold)) + .font(.system(size: 11, weight: .bold)) .tracking(0.44) .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(.system(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)) - } - 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)) - } + switch E2eeTrayLook.sectionStyle { + case .card: + list + .padding(16) + .background( + Color.white.opacity(E2eeTrayLook.sectionFillOpacity), + in: .rect(cornerRadius: E2eeTrayLook.sectionRadius, style: .continuous) + ) + case .plain: + list } - .symbolRenderingMode(.palette) - .font(.system(size: 18, weight: .semibold)) - .frame(width: 18, height: 18) } - private var lockSymbol: String { - switch kind { - case .dm, .privateGroup: "lock.fill" - case .group: "lock.open.fill" - } + private func bullet(isEncrypted: Bool) -> some View { + 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 title: String { @@ -162,17 +137,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 +168,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,8 +184,21 @@ struct E2eeFooter: View { } .buttonStyle(.plain) .accessibilityIdentifier("e2ee-footer-\(kind)") - .sheet(isPresented: $isShowingSheet) { + .tray(isPresented: $isShowingSheet) { E2eeLearnMoreSheet(kind: kind, isPresented: $isShowingSheet) } } } + +/// The E2EE tray's options while the look is tuned. // LOCAL-TUNER +@MainActor // LOCAL-TUNER +enum E2eeTrayLook { // LOCAL-TUNER + static var showsCTA: Bool { Tuner.value("Tray E2EE/Got It button", default: true) } // LOCAL-TUNER + static var sectionStyle: E2eeSectionStyle { Tuner.value("Tray E2EE/Section style", default: E2eeSectionStyle.card) } // LOCAL-TUNER + static var sectionRadius: CGFloat { Tuner.value("Tray E2EE/Section radius", default: 16, in: 0...32, unit: .points) } // LOCAL-TUNER + static var sectionFillOpacity: Double { Tuner.value("Tray E2EE/Section fill opacity", default: 0.05, in: 0...0.2) } // LOCAL-TUNER + static var sectionGap: CGFloat { Tuner.value("Tray E2EE/Section gap", default: 12, in: 0...32, unit: .points) } // LOCAL-TUNER +} // LOCAL-TUNER + +/// Whether each E2EE list sits on a card. // LOCAL-TUNER +enum E2eeSectionStyle: TunableEnum { case card, plain } // LOCAL-TUNER diff --git a/Flipcash/Core/Screens/Profile/MuteChatSheet.swift b/Flipcash/Core/Screens/Profile/MuteChatSheet.swift index 8eaa0717b..8dcd8b5c6 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,65 @@ 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: pattern == .selectThenConfirm && selection == row.option + ) { + guard !isWorking else { return } + switch pattern { + case .tapToApply: Task { await apply(row.option) } + case .selectThenConfirm: 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) - } + /// Whether a row applies itself or waits for the CTA; the tuner's "Tray Mute/Pattern". // LOCAL-TUNER + private var pattern: MuteTrayPattern { Tuner.value("Tray Mute/Pattern", default: MuteTrayPattern.selectThenConfirm) } // LOCAL-TUNER + + /// The CTA that applies the selected row, worded for an unmute when that is the pick. + private var actions: [TrayAction] { + switch pattern { + case .tapToApply: + return [] + case .selectThenConfirm: + return [ + TrayAction( + selection == .never ? "Unmute" : "Mute", + state: isWorking ? .loading : .normal, + isEnabled: selection != nil + ) { + guard let selection else { return } + Task { await apply(selection) } + }, + ] } } - /// 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) - } - - 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 } } @@ -191,3 +182,11 @@ struct MuteChatSheet: View { } } } + +/// How the mute tray's rows act. // LOCAL-TUNER +enum MuteTrayPattern: TunableEnum { // LOCAL-TUNER + /// A tap mutes at once, with no CTA. // LOCAL-TUNER + case tapToApply // LOCAL-TUNER + /// A tap selects; the CTA mutes. // LOCAL-TUNER + case selectThenConfirm // LOCAL-TUNER +} // LOCAL-TUNER diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index ae1dff37b..47dbaa82a 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..8446dcad5 100644 --- a/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift @@ -27,6 +27,10 @@ struct SettingsAdvancedBetaFeaturesScreen: View { @State private var unlinkAlertTitle: String? @State private var unlinkAlertMessage: String? @State private var isShowingUnlinkAlert: Bool = false + #if DEBUG // LOCAL-TUNER + /// The tray the showcase has up over this screen. // LOCAL-TUNER + @State private var showcaseTray: TrayKind? // LOCAL-TUNER + #endif // LOCAL-TUNER private let publicOptions = BetaFlags.Option.allCases.filter { $0.availability == .publicBeta } private let developerOptions = BetaFlags.Option.allCases.filter { $0.availability == .developer } @@ -47,6 +51,14 @@ struct SettingsAdvancedBetaFeaturesScreen: View { } } else { LazyTable(spacing: 0) { + #if DEBUG // LOCAL-TUNER + SettingsSectionHeader("Tray Showcase") // LOCAL-TUNER + ForEach(TrayKind.allCases) { kind in // LOCAL-TUNER + unlinkRow(title: kind.name, isDisabled: false) { showcaseTray = kind } // LOCAL-TUNER + .accessibilityIdentifier("tray-showcase-\(kind.rawValue)") // LOCAL-TUNER + } // LOCAL-TUNER + #endif // LOCAL-TUNER + ForEach(publicOptions) { option in BetaFlagToggleRow(option: option, isOn: betaFlags.bindingFor(option: option)) } @@ -80,6 +92,12 @@ struct SettingsAdvancedBetaFeaturesScreen: View { } .navigationTitle("Beta Features") .toolbarTitleDisplayMode(.inline) + #if DEBUG // LOCAL-TUNER + // Each showcase tray comes up over this screen, as it would over any other. // LOCAL-TUNER + .tray(item: $showcaseTray, isDismissable: showcaseTray?.isDismissable ?? true) { kind in // LOCAL-TUNER + kind.tray // LOCAL-TUNER + } // LOCAL-TUNER + #endif // LOCAL-TUNER .alert( "Unlink Email?", isPresented: $isConfirmingUnlinkEmail @@ -116,6 +134,7 @@ struct SettingsAdvancedBetaFeaturesScreen: View { /// True only when there's nothing at all to show — no public flags, no developer /// access to reveal the rest, and so no Contracts section either. private var isEmpty: Bool { + false && // LOCAL-TUNER: the Design section always shows !betaFlags.hasVisibleOptions && !betaFlags.accessGranted } 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..79dcb4e6f 100644 --- a/Flipcash/Core/Screens/Settings/SettingsRow.swift +++ b/Flipcash/Core/Screens/Settings/SettingsRow.swift @@ -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: String, 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..ac91643b2 100644 --- a/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift +++ b/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift @@ -16,15 +16,6 @@ struct SettingsSectionHeader: View { } 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/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 5f56ab60d..ae506b084 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -58,6 +58,7 @@ final class SessionAuthenticator { } var requiresUpgrade: Bool { + if true { return false } // LOCAL-TUNER: local dev builds skip the server's minimum-build gate guard let minBuildNumber = unauthenticatedUserFlags?.minBuildNumber, minBuildNumber > 0 else { return false // No minimum requirement or server didn't provide one } diff --git a/Flipcash/UI/EnterAmountHeader.swift b/Flipcash/UI/EnterAmountHeader.swift index 9869b36d2..d12331d88 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 66a659476..3aa31ffb2 100644 --- a/Flipcash/UI/EnterAmountView.swift +++ b/Flipcash/UI/EnterAmountView.swift @@ -103,6 +103,12 @@ 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 } @@ -118,6 +124,7 @@ public struct EnterAmountView: View { // absorbs the rest on taller devices. Spacer().frame(maxHeight: 24) header + .padding(.leading, Tuner.shared.value("Amount entry/Amount left inset", default: CGFloat(20), in: 0...48, unit: .points) - 20) // LOCAL-TUNER } else { Spacer() @@ -193,14 +200,16 @@ public struct EnterAmountView: View { .disabled(!actionEnabled(enteredAmount)) .padding(.top, 10) } 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), + // 22 below the keypad. + .padding(.top, Tuner.shared.value("Amount entry/Keypad to button", default: CGFloat(20), in: 0...48, unit: .points)) // LOCAL-TUNER + .padding(.horizontal, Tuner.shared.value("Amount entry/Button side inset", default: CGFloat(32), in: 16...56, unit: .points) - 20) // LOCAL-TUNER + .padding(.bottom, Tuner.shared.value("Amount entry/Button bottom inset", default: CGFloat(32), in: 0...80, unit: .points) - 54) // LOCAL-TUNER } } } @@ -284,3 +293,27 @@ 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 { + let height = Tuner.shared.value("Amount entry/Button height", default: CGFloat(54), in: 40...64, unit: .points) // LOCAL-TUNER + return configuration.label + .font(.appTextMedium) + .foregroundStyle(isEnabled ? Color.textAction : Color.textMain.opacity(0.2)) + .frame(maxWidth: .infinity) + .frame(height: height) + .background { + RoundedRectangle(cornerRadius: Tuner.shared.value("Amount entry/Button radius", default: CGFloat(27), in: 0...32, unit: .points), style: .continuous) // LOCAL-TUNER + .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..6b1bae851 100644 --- a/Flipcash/UI/View+Dialog.swift +++ b/Flipcash/UI/View+Dialog.swift @@ -8,8 +8,11 @@ import FlipcashUI extension View { /// Presents a `DialogItem`, reporting an analytics event for tracked items. + @ViewBuilder // LOCAL-TUNER func dialog(item: Binding) -> some View { + if DialogTrayModifier.isOn { modifier(DialogTrayModifier(item: item)) } else { // LOCAL-TUNER modifier(DialogItemModifier(item: item)) + } // LOCAL-TUNER } } @@ -59,3 +62,127 @@ private struct DialogItemModifier: ViewModifier { pendingDismiss = nil } } + +/// Presents a `DialogItem` as a tray: the same title, subtitle, style, dismissability, actions, // LOCAL-TUNER +/// analytics and `onDismiss` as the dialog, with an icon for the error and success styles. // LOCAL-TUNER +private struct DialogTrayModifier: ViewModifier { // LOCAL-TUNER + + /// Whether dialogs show as trays; the tuner's "Tray Behavior/Dialogs as trays". // LOCAL-TUNER + @MainActor static var isOn: Bool { Tuner.shared.value("Tray Behavior/Dialogs as trays", default: true) } // LOCAL-TUNER + + let item: Binding // LOCAL-TUNER + @Environment(AppRouter.self) private var router: AppRouter? // LOCAL-TUNER + + /// Captured while the tray is up so its `onDismiss` still fires when a drag clears the binding. // LOCAL-TUNER + @State private var pendingDismiss: (() -> Void)? // LOCAL-TUNER + + func body(content: Content) -> some View { // LOCAL-TUNER + content // LOCAL-TUNER + .tray(item: item, isDismissable: item.wrappedValue?.dismissable ?? true, onDismiss: runPendingDismiss) { presented in // LOCAL-TUNER + DialogTray(item: presented, dismiss: dismiss) // LOCAL-TUNER + .onAppear { // LOCAL-TUNER + pendingDismiss = presented.onDismiss // LOCAL-TUNER + guard presented.tracked, // LOCAL-TUNER + let title = presented.title, // LOCAL-TUNER + let subtitle = presented.subtitle else { return } // LOCAL-TUNER + Analytics.errorModalDisplayed( // LOCAL-TUNER + title: title, // LOCAL-TUNER + message: subtitle, // LOCAL-TUNER + screen: router?.presentedSheet?.description ?? "scan", // LOCAL-TUNER + callSite: nil // LOCAL-TUNER + ) // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + + private func dismiss() { // LOCAL-TUNER + item.wrappedValue = nil // LOCAL-TUNER + } // LOCAL-TUNER + + private func runPendingDismiss() { // LOCAL-TUNER + pendingDismiss?() // LOCAL-TUNER + pendingDismiss = nil // LOCAL-TUNER + } // LOCAL-TUNER +} // LOCAL-TUNER + +/// A `DialogItem` drawn as a single-step tray, its checkbox as the tray's content. // LOCAL-TUNER +private struct DialogTray: View { // LOCAL-TUNER + + let item: DialogItem // LOCAL-TUNER + let dismiss: () -> Void // LOCAL-TUNER + + @State private var isChecked = false // LOCAL-TUNER + + var body: some View { // LOCAL-TUNER + if let checkbox = item.checkbox { // LOCAL-TUNER + Tray(title: item.title ?? "", message: item.subtitle, icon: icon, style: style, actions: actions) { // LOCAL-TUNER + checkboxRow(checkbox) // LOCAL-TUNER + } // LOCAL-TUNER + } else { // LOCAL-TUNER + Tray(title: item.title ?? "", message: item.subtitle, icon: icon, style: style, actions: actions) // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + + private var style: TrayStyle { // LOCAL-TUNER + switch item.style { // LOCAL-TUNER + case .standard: .standard // LOCAL-TUNER + case .success: .success // LOCAL-TUNER + case .destructive: .error // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + + private var icon: TrayIcon? { // LOCAL-TUNER + switch item.style { // LOCAL-TUNER + case .standard: nil // LOCAL-TUNER + case .success: .central(.circleCheck) // LOCAL-TUNER + case .destructive: .destructive // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + + /// The dialog's actions as tray actions, each dismissing before it runs as the dialog's do. // LOCAL-TUNER + /// A second action that would share the first one's place takes the secondary's. // LOCAL-TUNER + private var actions: [TrayAction] { // LOCAL-TUNER + var taken: Set = [] // LOCAL-TUNER + return item.actions.map { action in // LOCAL-TUNER + // A lone destructive action is the red dialog's OK, not a warning. // LOCAL-TUNER + var role = item.actions.count == 1 ? .primary : Self.role(for: action.kind) // LOCAL-TUNER + let slot = role == .secondary ? 0 : 1 // LOCAL-TUNER + if taken.contains(slot) { // LOCAL-TUNER + role = slot == 0 ? .primary : .secondary // LOCAL-TUNER + } // LOCAL-TUNER + taken.insert(role == .secondary ? 0 : 1) // LOCAL-TUNER + return TrayAction(action.title, role: role) { // LOCAL-TUNER + dismiss() // LOCAL-TUNER + action.perform(isChecked: isChecked) // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + + private static func role(for kind: DialogAction.Kind) -> TrayAction.Role { // LOCAL-TUNER + switch kind { // LOCAL-TUNER + case .standard: .primary // LOCAL-TUNER + case .destructive: .destructive // LOCAL-TUNER + case .subtle, .outline: .secondary // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + + private func checkboxRow(_ checkbox: DialogCheckbox) -> some View { // LOCAL-TUNER + Button { // LOCAL-TUNER + isChecked.toggle() // LOCAL-TUNER + } label: { // LOCAL-TUNER + HStack(spacing: 10) { // LOCAL-TUNER + Image(systemName: isChecked ? "checkmark.square.fill" : "square") // LOCAL-TUNER + .font(.system(size: 20)) // LOCAL-TUNER + Text(checkbox.label) // LOCAL-TUNER + .font(.default(size: 14, weight: .medium)) // LOCAL-TUNER + .frame(maxWidth: .infinity, alignment: .leading) // LOCAL-TUNER + } // LOCAL-TUNER + .contentShape(Rectangle()) // LOCAL-TUNER + .padding(.vertical, 8) // LOCAL-TUNER + } // LOCAL-TUNER + .buttonStyle(.plain) // LOCAL-TUNER + .foregroundStyle(Color.textMain) // LOCAL-TUNER + .accessibilityAddTraits(isChecked ? .isSelected : []) // LOCAL-TUNER + .accessibilityIdentifier("dialog-checkbox") // LOCAL-TUNER + } // LOCAL-TUNER +} // LOCAL-TUNER diff --git a/Flipcash/Views/TutorialChecklistCard.swift b/Flipcash/Views/TutorialChecklistCard.swift index 690572a95..500a9d608 100644 --- a/Flipcash/Views/TutorialChecklistCard.swift +++ b/Flipcash/Views/TutorialChecklistCard.swift @@ -94,7 +94,7 @@ struct TutorialChecklistCard: View { @ViewBuilder private func icon(_ item: Item) -> some View { if item.isCompleted { - Image(systemName: "checkmark.circle.fill") + Image.icon(.circleCheck) .resizable() .scaledToFit() .foregroundStyle(Color.Sentiment.positive) 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/Package.swift b/FlipcashUI/Package.swift index b72ace312..bb92361d8 100644 --- a/FlipcashUI/Package.swift +++ b/FlipcashUI/Package.swift @@ -26,7 +26,7 @@ let sharedCore: Package.Dependency = { let package = Package( name: "FlipcashUI", platforms: [ - .iOS(.v18), + .iOS(.v26), // LOCAL-TUNER ], products: [ .library( @@ -35,6 +35,7 @@ let package = Package( ), ], dependencies: [ + .package(path: "../Packages/TunerKit"), // LOCAL-TUNER .package(path: "../FlipcashCore"), .package(url: "https://github.com/ekazaev/ChatLayout", from: "2.4.2"), .package(url: "https://github.com/ra1028/DifferenceKit", from: "1.3.0"), @@ -45,6 +46,7 @@ let package = Package( .target( name: "FlipcashUI", dependencies: [ + .product(name: "TunerKit", package: "TunerKit"), // LOCAL-TUNER .product(name: "FlipcashCore", package: "FlipcashCore"), .product(name: "ChatLayout", package: "ChatLayout", condition: .when(platforms: [.iOS])), .product(name: "DifferenceKit", package: "DifferenceKit"), 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..618e1deb3 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) } @@ -241,9 +239,9 @@ final class BubbleBackgroundView: UIView { /// flat against the transcript, measured at rgb 17 right up to its edge on all sides. Owning the /// values here also means the menu's lift and the edit that follows it share one shadow rather /// than one of them guessing at a system default the other inherited. - private static let liftShadowOpacity: Float = 0.135 - private static let liftShadowRadius: CGFloat = 20 - private static let liftShadowOffset = CGSize(width: 0, height: 10) + private static var liftShadowOpacity: Float { Float(Tuner.value("Chat v2 Lift/Shadow opacity", default: 0.135, in: 0...0.6, unit: .percent)) } // LOCAL-TUNER + private static var liftShadowRadius: CGFloat { Tuner.value("Chat v2 Lift/Shadow blur", default: 20, in: 0...60, unit: .points) } // LOCAL-TUNER + private static var liftShadowOffset: CGSize { CGSize(width: 0, height: Tuner.value("Chat v2 Lift/Shadow offset Y", default: 10, in: -20...40, unit: .points)) } // LOCAL-TUNER /// Raises `view` to the lifted plane. `shape` is the bubble's own path, so the shadow follows a /// flattened grouped corner instead of falling back to the view's square bounds. `nil` — a bare @@ -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..fdc770951 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatAuthorAvatarView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatAuthorAvatarView.swift @@ -20,11 +20,12 @@ import FlipcashCore final class ChatAuthorAvatarView: UIView { /// Figma sizes the gutter circle at 24pt (nodes 10125:19169-19184). - static let size: CGFloat = 24 + static var size: CGFloat { ChatStyle.authorAvatarSize } // LOCAL-TUNER private let imageView = UIImageView() private let monogram = UILabel() private let gradient = CAGradientLayer() + private lazy var sizeConstraints = [widthAnchor.constraint(equalToConstant: Self.size), heightAnchor.constraint(equalToConstant: Self.size)] // LOCAL-TUNER override init(frame: CGRect) { super.init(frame: frame) @@ -55,8 +56,8 @@ final class ChatAuthorAvatarView: UIView { addSubview(imageView) NSLayoutConstraint.activate([ - widthAnchor.constraint(equalToConstant: Self.size), - heightAnchor.constraint(equalToConstant: Self.size), + sizeConstraints[0], // LOCAL-TUNER + sizeConstraints[1], // LOCAL-TUNER monogram.centerXAnchor.constraint(equalTo: centerXAnchor), monogram.centerYAnchor.constraint(equalTo: centerYAnchor), imageView.leadingAnchor.constraint(equalTo: leadingAnchor), @@ -77,11 +78,13 @@ final class ChatAuthorAvatarView: UIView { CATransaction.setDisableActions(true) gradient.frame = bounds CATransaction.commit() + applyTuning() // LOCAL-TUNER } /// Draws `author`, using `imageData` when the bytes have arrived. Safe to call on every /// reconfigure — a repeat with the same inputs reads the decoded image straight out of the cache. func configure(with author: ChatAuthor, imageData: Data?) { + applyTuning() // LOCAL-TUNER let key = author.id.uuidString if let imageData, let image = ContactAvatarCache.shared.image(forKey: key, data: imageData) { imageView.image = image @@ -101,6 +104,16 @@ final class ChatAuthorAvatarView: UIView { accessibilityLabel = author.name.isEmpty ? "Contact" : author.name } + // LOCAL-TUNER: size, rounding, monogram and ring from the tuner, so a reload restyles a reused view. + func applyTuning() { // LOCAL-TUNER + let size = Self.size // LOCAL-TUNER + sizeConstraints.forEach { $0.constant = size } // LOCAL-TUNER + layer.cornerRadius = size / 2 // LOCAL-TUNER + monogram.font = .default(size: size * 16 / 44, weight: .bold) // LOCAL-TUNER + layer.borderWidth = ChatStyle.avatarStrokeWidth // LOCAL-TUNER + layer.borderColor = UIColor.white.withAlphaComponent(ChatStyle.avatarStrokeOpacity).cgColor // LOCAL-TUNER + } // LOCAL-TUNER + /// Clears the image so a recycled row never shows the previous author's face. func reset() { imageView.image = nil diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdrop.swift index e98642acf..e5fd21d65 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdrop.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdrop.swift @@ -24,7 +24,7 @@ enum ChatBackdrop { /// 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 + static var blurFraction: CGFloat { Tuner.value("Chat v2 Lift/Blur strength", default: 0.1, in: 0...1, unit: .percent) } // LOCAL-TUNER /// The black that goes with the blur wherever nothing else is darkening the screen. /// @@ -35,7 +35,7 @@ enum ChatBackdrop { /// 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 + static var dimAlpha: CGFloat { Tuner.value("Chat v2 Lift/Dim", default: 0.33, in: 0...0.9, unit: .percent) } // LOCAL-TUNER /// Applies the material to `view` at ``blurFraction``, and hands back the animator holding it /// there. @@ -45,10 +45,17 @@ enum ChatBackdrop { /// 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 { - let material = UIBlurEffect(style: .systemUltraThinMaterialDark) + let material = UIBlurEffect(style: Tuner.value("Chat v2 Lift/Material", default: ChatBlurMaterial.ultraThin).style) // LOCAL-TUNER let strength = UIViewPropertyAnimator(duration: 1, curve: .linear) { view.effect = material } strength.fractionComplete = blurFraction return strength } } + +nonisolated enum ChatBlurMaterial: String, TunableEnum, Sendable { // LOCAL-TUNER + case ultraThin, thin, regular, thick, chrome // LOCAL-TUNER + var style: UIBlurEffect.Style { // LOCAL-TUNER + switch self { case .ultraThin: .systemUltraThinMaterialDark; case .thin: .systemThinMaterialDark; case .regular: .systemMaterialDark; case .thick: .systemThickMaterialDark; case .chrome: .systemChromeMaterialDark } // LOCAL-TUNER + } // LOCAL-TUNER +} // LOCAL-TUNER #endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift index 16e8e1a1c..e2dd3b34d 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, @@ -218,6 +218,7 @@ public final class ChatBubbleView: UIView { updateAction: ChatBubbleAction? = nil ) { label.attributedText = Self.displayText(for: message) + editedLabel.textColor = ChatStyle.mutedText(isFromSelf: message.sender == .me) editedLabel.isHidden = !Self.showsEditedMarker(for: message) || message.rendersAsLargeEmoji isBare = message.rendersAsLargeEmoji @@ -269,7 +270,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 @@ -321,9 +322,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) @@ -373,19 +373,22 @@ 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 } + } + if isFromSelf, !isPlaceholder, let glow = ChatStyle.sentGlow { attributes[.shadow] = glow } // LOCAL-TUNER + 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 cbf4f4107..1e57dbf55 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift @@ -7,11 +7,12 @@ #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 +/// 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 currency flag + amount, hosted in a `ChatColumnCell`. 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, @@ -20,18 +21,26 @@ public final class ChatCashCardCell: ChatColumnCell { public static let reuseIdentifier = "ChatCashCardCell" - /// The card's fixed height; width now tracks `maxWidth`. - static let cardHeight: CGFloat = 170 - private let card = BubbleBackgroundView() private let reactionRow = ReactionPillRowView() private let coinIcon = UIImageView() + /// Stands in for the coin icon when the token has none: the dollar glyph on a green disc. + private let cashBadge = UIView() + private let dollarGlyph = UIImageView(image: UIImage.asset(.dollar).withRenderingMode(.alwaysTemplate)) private let tokenLabel = UILabel() private let flag = UIImageView() private let captionLabel = UILabel() private let amountLabel = UILabel() + private let centerStack = UIStackView() + private let amountRow = UIStackView() private var cardWidthConstraint: NSLayoutConstraint! private var reactionRowWidthConstraint: NSLayoutConstraint! + private var badgeSize: [NSLayoutConstraint] = [] + private var flagSize: [NSLayoutConstraint] = [] + private var headerTop: NSLayoutConstraint! + private var headerLeading: NSLayoutConstraint! + private var bodyTop: NSLayoutConstraint! + private var bodyBottom: 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. @@ -49,32 +58,43 @@ 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(cashBadge) + dollarGlyph.contentMode = .scaleAspectFit + dollarGlyph.tintColor = .white + dollarGlyph.translatesAutoresizingMaskIntoConstraints = false + cashBadge.addSubview(dollarGlyph) + NSLayoutConstraint.activate([ + dollarGlyph.centerXAnchor.constraint(equalTo: cashBadge.centerXAnchor), + dollarGlyph.centerYAnchor.constraint(equalTo: cashBadge.centerYAnchor), + // The glyph at 14 of the badge's 20, as drawn. + dollarGlyph.widthAnchor.constraint(equalTo: cashBadge.widthAnchor, multiplier: 0.7), + dollarGlyph.heightAnchor.constraint(equalTo: cashBadge.heightAnchor, multiplier: 0.7), + ]) + tokenLabel.textColor = ChatStyle.secondaryText - let tokenRow = UIStackView(arrangedSubviews: [coinIcon, tokenLabel]) - tokenRow.spacing = 4 + let tokenRow = UIStackView(arrangedSubviews: [coinIcon, cashBadge, tokenLabel]) + 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 - configureCircle(flag, diameter: 30) - amountLabel.font = .default(size: 38, weight: .bold) - amountLabel.textColor = .white + flagSize = configureCircle(flag) + amountLabel.textColor = ChatStyle.receivedText amountLabel.numberOfLines = 1 amountLabel.adjustsFontSizeToFitWidth = true amountLabel.minimumScaleFactor = 0.5 amountLabel.setContentCompressionResistancePriority(.required, for: .horizontal) - let amountRow = UIStackView(arrangedSubviews: [flag, amountLabel]) - amountRow.spacing = 11 + amountRow.addArrangedSubview(flag) + amountRow.addArrangedSubview(amountLabel) + amountRow.spacing = 8 amountRow.alignment = .center - let centerStack = UIStackView(arrangedSubviews: [captionLabel, amountRow]) + centerStack.addArrangedSubview(captionLabel) + centerStack.addArrangedSubview(amountRow) centerStack.axis = .vertical centerStack.spacing = 4 centerStack.alignment = .center @@ -86,26 +106,28 @@ public final class ChatCashCardCell: ChatColumnCell { reactionRow.onLongPress = { [weak self] emoji in self?.onReactionLongPress?(emoji) } reactionRow.onAdd = { [weak self] in self?.onReactionAdd?() } - // 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) - - 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) NSLayoutConstraint.activate([ cardWidthConstraint, reactionRowWidthConstraint, - cardHeight, - - tokenRow.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 11), - tokenRow.topAnchor.constraint(equalTo: card.topAnchor, constant: 8), - + headerTop, + headerLeading, + tokenRow.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor), + bodyTop, + bodyBottom, centerStack.centerXAnchor.constraint(equalTo: card.centerXAnchor), - centerStack.centerYAnchor.constraint(equalTo: card.centerYAnchor), centerStack.leadingAnchor.constraint(greaterThanOrEqualTo: card.leadingAnchor, constant: 16), centerStack.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -16), ]) + applyMetrics() } @available(*, unavailable) @@ -126,15 +148,37 @@ public final class ChatCashCardCell: ChatColumnCell { /// Fired when the trailing "+" is tapped, to open the picker. var onReactionAdd: (() -> 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) + captionLabel.font = .default(size: labelSize, weight: .regular) + amountLabel.font = .default(size: ChatStyle.cardAmountSize, weight: .semibold) + cashBadge.backgroundColor = ChatStyle.cashBadgeFill + let badge = ChatStyle.cardBadgeSize + badgeSize.forEach { $0.constant = badge } + coinIcon.layer.cornerRadius = badge / 2 + cashBadge.layer.cornerRadius = badge / 2 + let flagDiameter = ChatStyle.cardFlagSize + flagSize.forEach { $0.constant = flagDiameter } + flag.layer.cornerRadius = flagDiameter / 2 + headerTop.constant = ChatStyle.cardPaddingTop + headerLeading.constant = ChatStyle.cardPaddingX + bodyTop.constant = ChatStyle.cardHeaderGap + bodyBottom.constant = -ChatStyle.cardPaddingBottom } public override func prepareForReuse() { @@ -145,7 +189,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 @@ -159,15 +203,15 @@ public final class ChatCashCardCell: ChatColumnCell { flag.isHidden = flagImage == nil coinIcon.isHidden = cash.iconURL == nil + cashBadge.isHidden = cash.iconURL != nil coinIcon.kf.setImage(with: cash.iconURL) + 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 @@ -227,7 +271,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 fa66a1ad6..d11bf887f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift @@ -73,10 +73,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) @@ -164,7 +162,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 @@ -174,10 +172,23 @@ public class ChatColumnCell: UICollectionViewCell { /// as one transcript instead of a palette. private static let authorNameOpacity: Double = 0.5 + /// 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. - public static let authorGutterWidth = ChatAuthorAvatarView.size + 8 + public static var authorGutterWidth: CGFloat { ChatAuthorAvatarView.size + 8 } // LOCAL-TUNER /// Whether a drag starting at this point, in the cell's own coordinates, may reply to the row. /// @@ -310,7 +321,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..2084e69e9 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatDateSeparatorCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatDateSeparatorCell.swift @@ -7,6 +7,7 @@ #if canImport(UIKit) import UIKit +import SwiftUI /// A centered day + time header between runs of messages (e.g. "Today 12:13 PM"). Dumb — it /// renders the already-formatted string it's handed. @@ -35,7 +36,24 @@ 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) // LOCAL-TUNER } + + // LOCAL-TUNER: the day ("Oct 20", "Today") and the time ("15:40") set apart, each in its own type. + static func styled(_ text: String) -> NSAttributedString { // LOCAL-TUNER + let dayFont = UIFont.default(size: Tuner.value("Chat v2 Date/Day size", default: 12, in: 9...17, unit: .points), weight: Tuner.value("Chat v2 Date/Day weight", default: ChatTextWeight.medium).weight) // LOCAL-TUNER + let timeFont = UIFont.default(size: Tuner.value("Chat v2 Date/Time size", default: 12, in: 9...17, unit: .points), weight: Tuner.value("Chat v2 Date/Time weight", default: ChatTextWeight.regular).weight) // LOCAL-TUNER + let dayColor = UIColor(Tuner.value("Chat v2 Date/Day color", default: Color.white.opacity(0.5))) // LOCAL-TUNER + let timeColor = UIColor(Tuner.value("Chat v2 Date/Time color", default: Color.white.opacity(0.5))) // LOCAL-TUNER + let gap = Tuner.value("Chat v2 Date/Gap", default: 4, in: 0...16, unit: .points) // LOCAL-TUNER + guard let match = text.range(of: #"\d{1,2}[:.]\d{2}.*$"#, options: .regularExpression), match.lowerBound > text.startIndex else { // LOCAL-TUNER + return NSAttributedString(string: text, attributes: [.font: timeFont, .foregroundColor: timeColor]) // LOCAL-TUNER + } // LOCAL-TUNER + let day = 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 d94f11233..f21b9e5bd 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift @@ -121,8 +121,8 @@ public final class ChatMediaViewerController: UIViewController, UIScrollViewDele singleTap.require(toFail: doubleTap) scrollView.addGestureRecognizer(singleTap) - configure(closeButton, symbol: "xmark", label: "Close", action: #selector(closeTapped)) - configure(shareButton, symbol: "square.and.arrow.up", label: "Share", action: #selector(shareTapped)) + configure(closeButton, icon: .crossMedium, label: "Close", action: #selector(closeTapped)) + configure(shareButton, icon: .shareOs, label: "Share", action: #selector(shareTapped)) NSLayoutConstraint.activate([ scrollView.topAnchor.constraint(equalTo: view.topAnchor), @@ -147,8 +147,12 @@ public final class ChatMediaViewerController: UIViewController, UIScrollViewDele loadImage() } - private func configure(_ button: UIButton, symbol: String, label: String, action: Selector) { - button.configuration?.image = UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold)) + private func configure(_ button: UIButton, icon: Icon, label: String, action: Selector) { + // Redrawn at the glyph size, since a button configuration draws an asset at its own size. + let size = CGSize(width: 22, height: 22) + button.configuration?.image = UIGraphicsImageRenderer(size: size) + .image { _ in UIImage.icon(icon).draw(in: CGRect(origin: .zero, size: size)) } + .withRenderingMode(.alwaysTemplate) button.configuration?.baseForegroundColor = .white button.configuration?.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12) button.accessibilityLabel = label diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotionSandbox.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotionSandbox.swift index d16248749..342abbfe4 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotionSandbox.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotionSandbox.swift @@ -57,7 +57,15 @@ public final class ChatMotionSandboxViewController: UIViewController { /// The resting transcript the script runs on top of. Ends on a `.me` run, so the scripted send /// is a continuation and its top corner flattens — which is what makes the corner morph visible. - private let base: [ChatMessage] = ChatMessage.previewConversation(count: 8) + private let base: [ChatMessage] = ChatMessage.previewConversation(count: 8) + ChatMotionSandboxViewController.v2Samples // LOCAL-TUNER + // LOCAL-TUNER: a money card and reactions, so the Chat v2 sliders have something to move. + private static let v2Samples: [ChatMessage] = [ // LOCAL-TUNER + ChatMessage(id: "v2-text", content: .text("Splitting the cab from last night"), sender: .other), // LOCAL-TUNER + ChatMessage(id: "v2-cash", content: .cash(ChatCashContent(amount: "$12.00", token: "Cash", flagImageName: "us")), sender: .other, // LOCAL-TUNER + reactions: [ReactionPill(emoji: "❤️", count: 1, selfReacted: true, pending: false), ReactionPill(emoji: "😂", count: 2, selfReacted: false, pending: false)]), // LOCAL-TUNER + ChatMessage(id: "v2-thanks", content: .text("Thank you!"), sender: .me), // LOCAL-TUNER + ChatMessage(id: "v2-next", content: .text("Next one’s on me"), sender: .me), // LOCAL-TUNER + ] // LOCAL-TUNER /// The line `base`'s trailing own row carries until the scripted send takes it over. private static let baseReceipt = ChatReceipt.delivered @@ -272,7 +280,8 @@ public final class ChatMotionSandboxViewController: UIViewController { receipt: receipts[index], linkPreview: message.linkPreview, author: message.author, - isAttributedTranscript: message.isAttributedTranscript + isAttributedTranscript: message.isAttributedTranscript, + reactions: message.reactions // LOCAL-TUNER ) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift index 73c39eead..91fb95ca6 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift @@ -84,7 +84,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) @@ -115,7 +114,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 @@ -222,8 +220,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. @@ -245,17 +247,17 @@ 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, _): flagView.isHidden = true tokenLabel.isHidden = true - snippetLabel.textColor = Self.snippetColor + snippetLabel.textColor = snippetColor showThumbnail(blobID: thumbnailBlobID, location: thumbnail) case .text, .unavailable: flagView.isHidden = true 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/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 235fb222d..ce1794a4c 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -376,8 +376,30 @@ public final class ChatScreenViewController: UIViewController { set { imagePaste.onImages = newValue } } + // LOCAL-TUNER: moves the jump button when its height slider changes. + private func observeJumpLift() { // LOCAL-TUNER + withObservationTracking { _ = Self.jumpLift } onChange: { // LOCAL-TUNER + Task { @MainActor [weak self] in self?.jumpHostBottom?.constant = -Self.jumpLift; self?.observeJumpLift() } // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + + // LOCAL-TUNER: repaints the screen's own background surfaces when the tuner changes the app background. + private func observeBackground() { // LOCAL-TUNER + withObservationTracking { _ = Color.backgroundMain } onChange: { // LOCAL-TUNER + Task { @MainActor [weak self] in // LOCAL-TUNER + guard let self else { return } // LOCAL-TUNER + view.backgroundColor = UIColor(Color.backgroundMain) // LOCAL-TUNER + keyboardCornerCover.backgroundColor = UIColor(Color.backgroundMain) // LOCAL-TUNER + fade.applyColors() // LOCAL-TUNER + observeBackground() // LOCAL-TUNER + observeJumpLift() // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + public override func viewDidLoad() { super.viewDidLoad() + observeBackground() // LOCAL-TUNER view.backgroundColor = UIColor(Color.backgroundMain) // SwiftUI can rebuild the composer's field, so the paste handler goes on whichever one // starts editing. @@ -957,6 +979,9 @@ public final class ChatScreenViewController: UIViewController { /// Stands the button's box on the clip's top edge, so it rides the keyboard and every travel of /// that edge in the same layout pass as the clip. + private var jumpHostBottom: NSLayoutConstraint! // LOCAL-TUNER + private static var jumpLift: CGFloat { Tuner.value("Chat v2 Jump/Height above bottom", default: 56, in: 0...200, unit: .points) } // LOCAL-TUNER + private func addJumpButton() { guard let jumpButtonController else { return } let host = PassthroughView() @@ -969,10 +994,13 @@ public final class ChatScreenViewController: UIViewController { button.translatesAutoresizingMaskIntoConstraints = false host.addSubview(button) jumpButtonController.didMove(toParent: self) + // LOCAL-TUNER: held at a fixed height over the keyboard's top (the screen's foot while it's + // down), so the button stays put while the bar grows or settles between resting and typing. + jumpHostBottom = host.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -Self.jumpLift) // LOCAL-TUNER NSLayoutConstraint.activate([ host.leadingAnchor.constraint(equalTo: barClip.leadingAnchor), host.trailingAnchor.constraint(equalTo: barClip.trailingAnchor), - host.bottomAnchor.constraint(equalTo: barClip.topAnchor), + jumpHostBottom, // LOCAL-TUNER host.heightAnchor.constraint(equalToConstant: JumpToBottomButtonView.reach.height), button.topAnchor.constraint(equalTo: host.topAnchor), button.leadingAnchor.constraint(equalTo: host.leadingAnchor), diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift index 5ec80df32..40718213e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift @@ -249,7 +249,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..1d8935166 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatStyle.swift @@ -0,0 +1,306 @@ +// +// 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 var bubbleRadius: CGFloat { Tuner.value("Chat v2 Bubble/Radius", default: 20, in: 0...44, unit: .points) } // LOCAL-TUNER + /// The inner corner where two bubbles of one sender's run meet. + static var joinedRadius: CGFloat { Tuner.value("Chat v2 Bubble/Joined radius", default: 20, in: 0...30, unit: .points) } // LOCAL-TUNER + /// Text inset from a bubble's leading and trailing edges. + static var bubblePaddingX: CGFloat { Tuner.value("Chat v2 Bubble/Padding X", default: 14, in: 4...24, unit: .points) } // LOCAL-TUNER + /// Text inset from a bubble's top and bottom edges. + static var bubblePaddingY: CGFloat { Tuner.value("Chat v2 Bubble/Padding Y", default: 10, in: 2...16, unit: .points) } // LOCAL-TUNER + static var cornerStyleChoice: ChatCornerStyle { Tuner.value("Chat v2 Bubble/Corner style", default: ChatCornerStyle.continuous) } // LOCAL-TUNER + /// How a bubble's corners curve. + static var cornerStyle: RoundedCornerStyle { cornerStyleChoice.style } // LOCAL-TUNER + /// Message body point size. + static var bodySize: CGFloat { Tuner.value("Chat v2 Bubble/Text size", default: 17, in: 13...22, unit: .points) } // LOCAL-TUNER + static var bodyWeightChoice: ChatTextWeight { Tuner.value("Chat v2 Bubble/Text weight", default: ChatTextWeight.regular) } // LOCAL-TUNER + /// Message body weight. + static var bodyWeight: UIFont.Weight { bodyWeightChoice.weight } // LOCAL-TUNER + /// Message body line height. + static var bodyLineHeight: CGFloat { Tuner.value("Chat v2 Bubble/Line height", default: 22, in: 16...30, unit: .points) } // LOCAL-TUNER + /// Whether bubbles draw the faint hairline outline the v1 chat had. + static var drawsHairline: Bool { Tuner.value("Chat v2 Bubble/Hairline", default: false) } // LOCAL-TUNER + /// The widest a bubble may grow, as a share of the transcript's width. + static var maxWidthFraction: CGFloat { Tuner.value("Chat v2 Bubble/Max width", default: 0.78, in: 0.5...0.9, unit: .percent) } // LOCAL-TUNER + + /// The message body font. + static var 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 + + static var accent: ChatAccent { Tuner.value("Chat v2 Color/Accent", default: ChatAccent.blue) } // LOCAL-TUNER + /// The sent bubble's fill: the accent. + static var sentFill: UIColor { UIColor(accent.color.opacity(sentOpacity)) } // LOCAL-TUNER + /// How strongly the accent fills a sent bubble, over the conversation background. // LOCAL-TUNER + static var sentOpacity: Double { Tuner.value("Chat v2 Color/Sent bubble opacity", default: 0.32, in: 0.05...1, unit: .percent) } // LOCAL-TUNER + /// Text on a sent bubble. + static var sentText: UIColor { // LOCAL-TUNER + switch sentTextStyle { // LOCAL-TUNER + case .white: .white // LOCAL-TUNER + case .tinted: UIColor(Color.white.mix(with: accent.color, by: sentTextTint)) // LOCAL-TUNER + case .vivid: vividAccent // LOCAL-TUNER + case .auto: UIColor(accent.onColor) // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + /// The accent pushed toward full brightness, keeping its hue, for text that glows against a faint bubble. // LOCAL-TUNER + static var vividAccent: UIColor { // LOCAL-TUNER + var h: CGFloat = 0, sat: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 // LOCAL-TUNER + UIColor(accent.color).getHue(&h, saturation: &sat, brightness: &b, alpha: &a) // LOCAL-TUNER + let hsb = UIColor(hue: h, saturation: min(1, sat * vividSaturation), brightness: max(b, vividBrightness), alpha: 1).mixed(withWhite: vividLift) // LOCAL-TUNER + var r: CGFloat = 0, g: CGFloat = 0, bl: CGFloat = 0 // LOCAL-TUNER + hsb.getRed(&r, green: &g, blue: &bl, alpha: &a) // LOCAL-TUNER + // The same components read in Display P3 land further out than sRGB: the iPhone's own, wider gamut. // LOCAL-TUNER + return vividInP3 ? UIColor(displayP3Red: r, green: g, blue: bl, alpha: 1) : hsb // LOCAL-TUNER + } // LOCAL-TUNER + static var vividSaturation: CGFloat { Tuner.value("Chat v2 Text/Vivid saturation", default: 1.5, in: 0...1.5, unit: .multiplier) } // LOCAL-TUNER + static var vividBrightness: CGFloat { Tuner.value("Chat v2 Text/Vivid brightness", default: 0.946, in: 0.5...1) } // LOCAL-TUNER + static var vividInP3: Bool { Tuner.value("Chat v2 Text/Vivid in P3", default: true) } // LOCAL-TUNER + /// White mixed into vivid text, so a deep accent still reads light. // LOCAL-TUNER + static var vividLift: CGFloat { Tuner.value("Chat v2 Text/Vivid lift", default: 0.88, in: 0...1, unit: .percent) } // LOCAL-TUNER + /// A soft accent glow behind sent text; 0 draws none. // LOCAL-TUNER + static var sentGlowRadius: CGFloat { Tuner.value("Chat v2 Text/Glow radius", default: 0, in: 0...12, unit: .points) } // LOCAL-TUNER + static var sentGlowOpacity: CGFloat { Tuner.value("Chat v2 Text/Glow opacity", default: 0.8, in: 0...1, unit: .percent) } // LOCAL-TUNER + /// The glow under sent text, or nil when it is off. // LOCAL-TUNER + static var sentGlow: NSShadow? { // LOCAL-TUNER + guard sentGlowRadius > 0 else { return nil } // LOCAL-TUNER + let glow = NSShadow() // LOCAL-TUNER + glow.shadowColor = UIColor(accent.color).withAlphaComponent(sentGlowOpacity) // LOCAL-TUNER + glow.shadowBlurRadius = sentGlowRadius // LOCAL-TUNER + glow.shadowOffset = .zero // LOCAL-TUNER + return glow // LOCAL-TUNER + } // LOCAL-TUNER + static var sentTextStyle: ChatSentTextStyle { Tuner.value("Chat v2 Color/Sent text", default: ChatSentTextStyle.vivid) } // LOCAL-TUNER + /// How much of the accent the tinted sent text takes. // LOCAL-TUNER + static var sentTextTint: Double { Tuner.value("Chat v2 Color/Sent text tint", default: 0.175, in: 0...1, unit: .percent) } // LOCAL-TUNER + /// The received bubble's fill. + static var receivedFill: UIColor { UIColor(Tuner.value("Chat v2 Color/Received bubble", default: Color(r: 35, g: 35, b: 37))) } // LOCAL-TUNER + /// 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 var accentTint: UIColor { UIColor(accent.color.opacity(0.16)) } // LOCAL-TUNER + /// The ring around a reaction the viewer added. + static var accentRing: UIColor { UIColor(accent.color.opacity(0.5)) } // LOCAL-TUNER + /// The accent itself, for text and glyphs. + static var accentColor: UIColor { UIColor(accent.color) } // LOCAL-TUNER + + /// 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 var authorNameSize: CGFloat { Tuner.value("Chat v2 Group/Name size", default: 12, in: 10...17, unit: .points) } // LOCAL-TUNER + static var authorNameWeightChoice: ChatTextWeight { Tuner.value("Chat v2 Group/Name weight", default: ChatTextWeight.regular) } // LOCAL-TUNER + static var authorNameWeight: UIFont.Weight { authorNameWeightChoice.weight } // LOCAL-TUNER + + // MARK: - Avatars + + /// A hairline ring around avatars, so a dark photo still reads as a circle. // LOCAL-TUNER + static var avatarStrokeWidth: CGFloat { Tuner.value("Chat v2 Avatar/Stroke width", default: 1, in: 0...3, unit: .points) } // LOCAL-TUNER + static var avatarStrokeOpacity: CGFloat { Tuner.value("Chat v2 Avatar/Stroke opacity", default: 0.05, in: 0...1, unit: .percent) } // LOCAL-TUNER + /// The avatar beside a group author's first bubble. // LOCAL-TUNER + static var authorAvatarSize: CGFloat { Tuner.value("Chat v2 Avatar/In-chat size", default: 28, in: 16...44, unit: .points) } // LOCAL-TUNER + + // MARK: - Swipe to reply + + static var replyIconSize: CGFloat { Tuner.value("Chat v2 Reply/Icon size", default: 24, in: 10...32, unit: .points) } // LOCAL-TUNER + static var replyIconColor: Color { Tuner.value("Chat v2 Reply/Icon color", default: Color.white.opacity(0.4)) } // LOCAL-TUNER + + // MARK: - Mentions + + static var mentionStyle: ChatMentionStyle { Tuner.value("Chat v2 Mentions/Style", default: ChatMentionStyle.pill) } // LOCAL-TUNER + static var mentionPillFill: Color { Tuner.value("Chat v2 Mentions/Pill fill", default: Color.white.opacity(0.14)) } // LOCAL-TUNER + static var mentionPillFillSent: Color { Tuner.value("Chat v2 Mentions/Pill fill on sent", default: Color.white.opacity(0.14)) } // LOCAL-TUNER + static var mentionPaddingX: CGFloat { Tuner.value("Chat v2 Mentions/Padding X", default: 5.75, in: 0...14, unit: .points) } // LOCAL-TUNER + static var mentionPaddingY: CGFloat { Tuner.value("Chat v2 Mentions/Padding Y", default: 0, in: -2...8, unit: .points) } // LOCAL-TUNER + static var mentionGap: CGFloat { Tuner.value("Chat v2 Mentions/Gap to words", default: 0, in: 0...10, unit: .points) } // LOCAL-TUNER + static var mentionRadius: CGFloat { Tuner.value("Chat v2 Mentions/Corner radius", default: 7, in: 0...16, unit: .points) } // LOCAL-TUNER + static var mentionCapsule: Bool { Tuner.value("Chat v2 Mentions/Capsule", default: true) } // LOCAL-TUNER + static var mentionSize: CGFloat { Tuner.value("Chat v2 Mentions/Text size", default: 12.5, in: 10...22, unit: .points) } // LOCAL-TUNER + static var mentionBaselineNudge: CGFloat { Tuner.value("Chat v2 Mentions/Baseline nudge", default: 0.5, in: -4...4, unit: .points) } // LOCAL-TUNER + static var mentionWeight: ChatTextWeight { Tuner.value("Chat v2 Mentions/Weight", default: ChatTextWeight.semibold) } // LOCAL-TUNER + static var mentionColorMode: ChatMentionColor { Tuner.value("Chat v2 Mentions/Text color", default: ChatMentionColor.custom) } // LOCAL-TUNER + static var mentionCustomColor: Color { Tuner.value("Chat v2 Mentions/Custom color", default: Color.white.opacity(0.77)) } // LOCAL-TUNER + static var mentionUnderline: Bool { Tuner.value("Chat v2 Mentions/Underline", default: false) } // LOCAL-TUNER + /// A mention's text colour on a sender's bubble, or nil to keep the body's. // LOCAL-TUNER + static func mentionText(isFromSelf: Bool) -> UIColor? { // LOCAL-TUNER + switch mentionColorMode { // LOCAL-TUNER + case .inherit: nil // LOCAL-TUNER + case .accent: isFromSelf ? text(isFromSelf: true) : accentColor // LOCAL-TUNER + case .vivid: vividAccent // LOCAL-TUNER + case .custom: UIColor(mentionCustomColor) // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + + // MARK: - Spacing + + /// Between two bubbles of one sender's run. + static var runGap: CGFloat { Tuner.value("Chat v2 Spacing/Within a run", default: 4, in: 0...12, unit: .points) } // LOCAL-TUNER + /// Between two messages of one sender that the grouping window broke apart, and around a date. + static var momentGap: CGFloat { Tuner.value("Chat v2 Spacing/Same sender, new moment", default: 10, in: 0...24, unit: .points) } // LOCAL-TUNER + /// Between two speakers. + static var speakerGap: CGFloat { Tuner.value("Chat v2 Spacing/Speaker change", default: 16, in: 0...32, unit: .points) } // LOCAL-TUNER + /// The transcript's side margin. + static var rowInset: CGFloat { Tuner.value("Chat v2 Spacing/Side margin", default: 16, in: 8...24, unit: .points) } // LOCAL-TUNER + + // MARK: - Money card + + /// The money card's fill: neutral, so money reads the same under any accent. + static var cardFill: UIColor { UIColor(Tuner.value("Chat v2 Money/Card fill", default: Color(r: 28, g: 28, b: 30))) } // LOCAL-TUNER + static var cardRadius: CGFloat { Tuner.value("Chat v2 Money/Radius", default: 22, in: 8...32, unit: .points) } // LOCAL-TUNER + static var cardWidth: CGFloat { Tuner.value("Chat v2 Money/Width", default: 240, in: 180...320, unit: .points) } // LOCAL-TUNER + static var cardPaddingX: CGFloat { Tuner.value("Chat v2 Money/Padding X", default: 16, in: 8...28, unit: .points) } // LOCAL-TUNER + static var cardPaddingTop: CGFloat { Tuner.value("Chat v2 Money/Padding top", default: 14, in: 4...28, unit: .points) } // LOCAL-TUNER + static var cardPaddingBottom: CGFloat { Tuner.value("Chat v2 Money/Padding bottom", default: 22, in: 4...36, unit: .points) } // LOCAL-TUNER + /// Between the card's header and its caption. + static var cardHeaderGap: CGFloat { Tuner.value("Chat v2 Money/Header gap", default: 14, in: 0...32, unit: .points) } // LOCAL-TUNER + /// The token badge in the header. + static var cardBadgeSize: CGFloat { Tuner.value("Chat v2 Money/Badge size", default: 20, in: 12...32, unit: .points) } // LOCAL-TUNER + static var cardLabelSize: CGFloat { Tuner.value("Chat v2 Money/Label size", default: 13, in: 10...17, unit: .points) } // LOCAL-TUNER + static var cardAmountSize: CGFloat { Tuner.value("Chat v2 Money/Amount size", default: 34, in: 24...48, unit: .points) } // LOCAL-TUNER + static var cardFlagSize: CGFloat { Tuner.value("Chat v2 Money/Flag size", default: 30, in: 20...40, unit: .points) } // LOCAL-TUNER + /// The fill behind the cash badge's dollar glyph. + static var cashBadgeFill: UIColor { UIColor(Tuner.value("Chat v2 Money/Cash green", default: Color(r: 48, g: 209, b: 88))) } // LOCAL-TUNER + + // MARK: - Reactions + + static var pillHeight: CGFloat { Tuner.value("Chat v2 Reactions/Height", default: 32, in: 20...36, unit: .points) } // LOCAL-TUNER + static var pillLeadingPadding: CGFloat { Tuner.value("Chat v2 Reactions/Padding leading", default: 10, in: 2...16, unit: .points) } // LOCAL-TUNER + static var pillTrailingPadding: CGFloat { Tuner.value("Chat v2 Reactions/Padding trailing", default: 2, in: 2...16, unit: .points) } // LOCAL-TUNER + /// Between pills, and between the message and its pills. + static var pillGap: CGFloat { Tuner.value("Chat v2 Reactions/Gap", default: 4, in: 0...12, unit: .points) } // LOCAL-TUNER + /// Between a pill's emoji and its count. + static var pillInnerGap: CGFloat { Tuner.value("Chat v2 Reactions/Emoji to count", default: 4, in: 0...12, unit: .points) } // LOCAL-TUNER + static var pillCountRoom: Int { Tuner.value("Chat v2 Reactions/Count room (digits)", default: 2, in: 1...3) } // LOCAL-TUNER + static var pillEmojiSize: CGFloat { Tuner.value("Chat v2 Reactions/Emoji size", default: 13, in: 11...22, unit: .points) } // LOCAL-TUNER + static var pillCountSize: CGFloat { Tuner.value("Chat v2 Reactions/Count size", default: 13, in: 10...17, unit: .points) } // LOCAL-TUNER + static var pillRingWidth: CGFloat { Tuner.value("Chat v2 Reactions/Ring width", default: 1.75, in: 0...3, unit: .points) } // LOCAL-TUNER + /// A reaction nobody here added. + static var pillFill: UIColor { UIColor(Tuner.value("Chat v2 Reactions/Fill", default: Color.white.opacity(0.09))) } // LOCAL-TUNER + /// Whether the add-reaction button sits on a glass circle, as in v1. + static var addReactionGlass: Bool { Tuner.value("Chat v2 Reactions/Add on glass", default: false) } // LOCAL-TUNER + /// The add-reaction glyph. + static var addReactionIconSize: CGFloat { Tuner.value("Chat v2 Reactions/Add icon size", default: 19.3, in: 12...24, unit: .points) } // LOCAL-TUNER + + // LOCAL-TUNER: reads every value once, so all of them register in the panel and the transcript can observe them. + static func readAll() -> [Any] { // LOCAL-TUNER + [ChatDateSeparatorCell.styled("Oct 20 15:40"), Color.backgroundMain, replyIconSize, replyIconColor, mentionStyle, mentionPillFill, mentionPillFillSent, mentionPaddingX, mentionPaddingY, mentionGap, mentionRadius, mentionCapsule, mentionSize, mentionBaselineNudge, mentionWeight, mentionColorMode, mentionCustomColor, mentionUnderline, avatarStrokeWidth, avatarStrokeOpacity, authorAvatarSize, authorNameSize, authorNameWeightChoice, bubbleRadius, joinedRadius, cornerStyleChoice, bubblePaddingX, bubblePaddingY, bodySize, bodyWeightChoice, bodyLineHeight, drawsHairline, maxWidthFraction, // LOCAL-TUNER + accent, ChatAccent.custom.color, sentTextStyle, sentTextTint, sentOpacity, vividSaturation, vividBrightness, vividLift, vividInP3, sentGlowRadius, sentGlowOpacity, receivedFill, runGap, momentGap, speakerGap, rowInset, // LOCAL-TUNER + cardFill, cardRadius, cardWidth, cardPaddingX, cardPaddingTop, cardPaddingBottom, cardHeaderGap, cardBadgeSize, cardLabelSize, cardAmountSize, cardFlagSize, cashBadgeFill, // LOCAL-TUNER + pillHeight, pillLeadingPadding, pillTrailingPadding, pillGap, pillInnerGap, pillCountRoom, pillEmojiSize, pillCountSize, pillRingWidth, pillFill, addReactionGlass, addReactionIconSize] // LOCAL-TUNER + } // LOCAL-TUNER +} + +// LOCAL-TUNER: the choices behind the tuner's pickers. Baking keeps the picked value only. +nonisolated enum ChatAccent: String, TunableEnum, Sendable { // LOCAL-TUNER + case ink, graphite, smoke, blue, sky, cyan, teal, mint, lime, yellow, amber, orange, person, coral, red, rose, pink, magenta, purple, violet, indigo, navy, forest, brown, sand, custom // LOCAL-TUNER + var color: Color { // LOCAL-TUNER + switch self { // LOCAL-TUNER + case .ink: Self.hex(0xF2F2F5) // LOCAL-TUNER + case .graphite: Self.hex(0x48484A) // LOCAL-TUNER + case .smoke: Self.hex(0x8E8E93) // LOCAL-TUNER + case .blue: Self.hex(0x0A84FF) // LOCAL-TUNER + case .sky: Self.hex(0x5AC8FA) // LOCAL-TUNER + case .cyan: Self.hex(0x64D2FF) // LOCAL-TUNER + case .teal: Self.hex(0x30B0C7) // LOCAL-TUNER + case .mint: Self.hex(0x63E6BE) // LOCAL-TUNER + case .lime: Self.hex(0xA8E05F) // LOCAL-TUNER + case .yellow: Self.hex(0xFFD60A) // LOCAL-TUNER + case .amber: Self.hex(0xFFB340) // LOCAL-TUNER + case .orange: Self.hex(0xFF9F0A) // LOCAL-TUNER + case .person: Self.hex(0xE0723F) // LOCAL-TUNER + case .coral: Self.hex(0xFF6B5A) // LOCAL-TUNER + case .red: Self.hex(0xFF453A) // LOCAL-TUNER + case .rose: Self.hex(0xFF375F) // LOCAL-TUNER + case .pink: Self.hex(0xFF6FB5) // LOCAL-TUNER + case .magenta: Self.hex(0xE040A0) // LOCAL-TUNER + case .purple: Self.hex(0xBF5AF2) // LOCAL-TUNER + case .violet: Self.hex(0x7457FF) // LOCAL-TUNER + case .indigo: Self.hex(0x5E5CE6) // LOCAL-TUNER + case .navy: Self.hex(0x2C4BD4) // LOCAL-TUNER + case .forest: Self.hex(0x2E8B57) // LOCAL-TUNER + case .brown: Self.hex(0xAC8E68) // LOCAL-TUNER + case .sand: Self.hex(0xD8C3A5) // LOCAL-TUNER + case .custom: Tuner.value("Chat v2 Color/Custom accent", default: Self.hex(0x0A84FF)) // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + /// Near-black on a light accent, white on a dark one, picked by the accent's luminance. // LOCAL-TUNER + var onColor: Color { // LOCAL-TUNER + let c = UIColor(color).cgColor.converted(to: CGColorSpace(name: CGColorSpace.sRGB)!, intent: .defaultIntent, options: nil)?.components ?? [0, 0, 0] // LOCAL-TUNER + let lin = { (v: CGFloat) in v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4) } // LOCAL-TUNER + let luminance = 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]) // LOCAL-TUNER + return luminance > 0.4 ? Self.hex(0x0B0B0C) : .white // LOCAL-TUNER + } // LOCAL-TUNER + private static func hex(_ value: Int) -> Color { // LOCAL-TUNER + Color(red: Double((value >> 16) & 0xFF) / 255, green: Double((value >> 8) & 0xFF) / 255, blue: Double(value & 0xFF) / 255) // LOCAL-TUNER + } // LOCAL-TUNER +} // LOCAL-TUNER + +nonisolated enum ChatSentTextStyle: String, TunableEnum, Sendable { // LOCAL-TUNER + case white, tinted, vivid, auto // LOCAL-TUNER +} // LOCAL-TUNER + +nonisolated enum ChatMentionStyle: String, TunableEnum, Sendable { case pill, plain } // LOCAL-TUNER +nonisolated enum ChatMentionColor: String, TunableEnum, Sendable { case inherit, accent, vivid, custom } // LOCAL-TUNER + +nonisolated enum ChatCornerStyle: String, TunableEnum, Sendable { // LOCAL-TUNER + case circular, continuous // LOCAL-TUNER + var style: RoundedCornerStyle { self == .circular ? .circular : .continuous } // LOCAL-TUNER +} // LOCAL-TUNER + +nonisolated enum ChatTextWeight: String, TunableEnum, Sendable { // LOCAL-TUNER + case regular, medium, semibold // LOCAL-TUNER + var weight: UIFont.Weight { // LOCAL-TUNER + switch self { case .regular: .regular; case .medium: .medium; case .semibold: .semibold } // LOCAL-TUNER + } // LOCAL-TUNER +} // LOCAL-TUNER + +private extension UIColor { // LOCAL-TUNER + func mixed(withWhite amount: CGFloat) -> UIColor { // LOCAL-TUNER + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 // LOCAL-TUNER + getRed(&r, green: &g, blue: &b, alpha: &a) // LOCAL-TUNER + return UIColor(red: r + (1 - r) * amount, green: g + (1 - g) * amount, blue: b + (1 - b) * amount, alpha: a) // LOCAL-TUNER + } // LOCAL-TUNER +} // LOCAL-TUNER +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatSwipeToReply.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSwipeToReply.swift index e7ffe59b9..633f6ba9a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatSwipeToReply.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSwipeToReply.swift @@ -60,17 +60,25 @@ 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.image = UIImage.icon(.arrowShareLeftFilled) affordanceIcon.tintColor = UIColor.white.withAlphaComponent(0.75) affordanceIcon.contentMode = .scaleAspectFit affordanceIcon.frame = affordance.bounds.insetBy(dx: 8, dy: 8) affordance.addSubview(affordanceIcon) } + // LOCAL-TUNER: the reply glyph's size and colour from the tuner. + private func applyTuning() { // LOCAL-TUNER + let size = ChatStyle.replyIconSize // LOCAL-TUNER + let box = Self.affordanceDiameter // LOCAL-TUNER + affordanceIcon.frame = CGRect(x: (box - size) / 2, y: (box - size) / 2, width: size, height: size) // LOCAL-TUNER + affordanceIcon.tintColor = UIColor(ChatStyle.replyIconColor) // LOCAL-TUNER + } // LOCAL-TUNER + /// Whether a drag with this velocity is a reply swipe rather than a scroll. /// /// Horizontal dominance is the whole rule: a diagonal drag belongs to the scroll view, which @@ -154,6 +162,7 @@ final class ChatSwipeToReply: NSObject { haptics.prepare() affordance.alpha = 0 + applyTuning() // LOCAL-TUNER affordance.transform = CGAffineTransform(scaleX: Self.arrowStartScale, y: Self.arrowStartScale) affordance.center = Self.affordanceCenter(inRowOfHeight: row.cell.bounds.height) row.cell.contentView.addSubview(affordance) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift index cd4a35d44..58cc02b0d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift @@ -28,9 +28,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) @@ -61,6 +61,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..af272b83d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatTypingIndicatorCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatTypingIndicatorCell.swift @@ -37,7 +37,7 @@ public final class ChatTypingIndicatorCell: UICollectionViewCell { // Android's `TypingIndicator`: the transcript's 24pt 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 var avatarSize: CGFloat { ChatAuthorAvatarView.size } // LOCAL-TUNER private static let avatarOverlap: CGFloat = 8 private static let avatarGap: CGFloat = 4 private static let avatarRing: CGFloat = 2 diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 13459227d..82dd8d5be 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -138,16 +138,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 @@ -190,14 +186,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() @@ -328,8 +324,36 @@ public final class ChatViewController: UICollectionViewController { @available(*, unavailable) public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + private var tunerRestylePending = false // LOCAL-TUNER + // LOCAL-TUNER: reloads without animation and puts the transcript back where its bottom was. + private func restyleForTuner() { // LOCAL-TUNER + tunerRestylePending = false // LOCAL-TUNER + guard !isUpdating else { tunerRestylePending = true; DispatchQueue.main.async { [weak self] in self?.restyleForTuner() }; return } // LOCAL-TUNER + UIView.performWithoutAnimation { // LOCAL-TUNER + collectionView.backgroundColor = UIColor(Color.backgroundMain) // LOCAL-TUNER + let snapshot = chatLayout.getContentOffsetSnapshot(from: .bottom) // LOCAL-TUNER + collectionView.reloadData() // LOCAL-TUNER + if let snapshot { chatLayout.restoreContentOffset(with: snapshot) } // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + + // LOCAL-TUNER: redraws the transcript whenever a Chat v2 tuner value changes. + private func observeChatStyle() { // LOCAL-TUNER + withObservationTracking { _ = ChatStyle.readAll() } onChange: { // LOCAL-TUNER + Task { @MainActor [weak self] in // LOCAL-TUNER + guard let self else { return } // LOCAL-TUNER + observeChatStyle() // LOCAL-TUNER + guard !tunerRestylePending else { return } // LOCAL-TUNER + tunerRestylePending = true // LOCAL-TUNER + // One restyle per frame however fast the slider moves, holding the bottom in place. // LOCAL-TUNER + DispatchQueue.main.async { [weak self] in self?.restyleForTuner() } // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + public override func viewDidLoad() { super.viewDidLoad() + observeChatStyle() // LOCAL-TUNER collectionView.backgroundColor = UIColor(Color.backgroundMain) collectionView.alwaysBounceVertical = true collectionView.keyboardDismissMode = .interactive @@ -1592,7 +1616,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 { @@ -1791,16 +1815,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..99684fb67 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift @@ -34,11 +34,11 @@ public final class JumpToBottomModel { /// The jump-to-bottom button: a 44pt 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 var diameter: CGFloat { Tuner.value("Chat v2 Jump/Size", default: 36, in: 28...64, unit: .points) } // LOCAL-TUNER /// 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) - private static let badgeHeight: CGFloat = 22 + public static var reach: CGSize { CGSize(width: 88, height: 20 + diameter + 12) } // LOCAL-TUNER + private static var badgeHeight: CGFloat { Tuner.value("Chat v2 Jump/Badge size", default: 22, in: 14...32, unit: .points) } // LOCAL-TUNER public let unreadCount: Int public let action: () -> Void @@ -54,8 +54,8 @@ 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(Tuner.value("Chat v2 Jump/Icon size", default: 22, in: 12...32, unit: .points)) // LOCAL-TUNER .foregroundStyle(.white) .frame(width: Self.diameter, height: Self.diameter) .composerGlass(in: Circle(), id: "jump", namespace: glassNamespace) 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 21b524afd..13165b866 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,8 @@ 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) + textView.isSent = message.sender == .me // LOCAL-TUNER // 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. @@ -349,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 { @@ -384,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 @@ -458,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. /// @@ -481,6 +486,15 @@ extension LinkableBubbleView { for mention in message.linkPreview?.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 isFromSelf = message.sender == .me // LOCAL-TUNER + let mentionFont = UIFont.default(size: ChatStyle.mentionSize, weight: ChatStyle.mentionWeight.weight) // LOCAL-TUNER + result.addAttribute(.font, value: mentionFont, range: mention.range) // LOCAL-TUNER + // Lifts a smaller handle so its capitals centre on the body's, instead of sitting on its baseline. // LOCAL-TUNER + let centring = (ChatStyle.bodyFont.capHeight - mentionFont.capHeight) / 2 + ChatStyle.mentionBaselineNudge // LOCAL-TUNER + let bodyOffset = (ChatStyle.bodyLineAttributes(for: ChatStyle.bodyFont)[.baselineOffset] as? CGFloat) ?? 0 // LOCAL-TUNER + result.addAttribute(.baselineOffset, value: bodyOffset + centring, range: mention.range) // LOCAL-TUNER + if let color = ChatStyle.mentionText(isFromSelf: isFromSelf) { result.addAttribute(.foregroundColor, value: color, range: mention.range) } // LOCAL-TUNER + if ChatStyle.mentionUnderline { result.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: mention.range) } // LOCAL-TUNER // 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 { @@ -555,18 +569,19 @@ extension LinkableBubbleView: UITextViewDelegate { /// The rounded background drawn behind an `@handle`. enum MentionPill { - static let fill = UIColor.white.withAlphaComponent(0.14) + static var fill: UIColor { UIColor(ChatStyle.mentionPillFill) } // LOCAL-TUNER + static var isDrawn: Bool { ChatStyle.mentionStyle == .pill } // LOCAL-TUNER /// Horizontal room between the handle's glyphs and the pill's edge. - static let padding: CGFloat = 6 + static var padding: CGFloat { isDrawn ? ChatStyle.mentionPaddingX : 0 } // LOCAL-TUNER /// Space between the pill's edge and the neighbouring words. - static let gap: CGFloat = 3 + static var gap: CGFloat { isDrawn ? ChatStyle.mentionGap : 0 } // LOCAL-TUNER /// 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 + static var spacing: CGFloat { padding + gap } // LOCAL-TUNER + static var cornerRadius: CGFloat { ChatStyle.mentionRadius } // LOCAL-TUNER /// The pill for one line of a mention, from that line's selection rect. static func rect(around line: CGRect) -> CGRect { - line.insetBy(dx: -padding, dy: 0) + line.insetBy(dx: -padding, dy: 0) // LOCAL-TUNER } } @@ -575,6 +590,8 @@ enum MentionPill { /// ChatLayout's own `MessageTextView` recipe. final class LinkTextView: UITextView { private let pills = CAShapeLayer() + /// Whether this text sits in a sent bubble, whose mention pills take their own fill. // LOCAL-TUNER + var isSent = false { didSet { setNeedsLayout() } } // LOCAL-TUNER override init(frame: CGRect, textContainer: NSTextContainer?) { super.init(frame: frame, textContainer: textContainer) @@ -597,13 +614,24 @@ final class LinkTextView: UITextView { /// One pill per line a mention covers, behind the text, so a wrapped handle gets one on each line. private func drawPills() { let path = UIBezierPath() - for rect in mentionPillRects { - path.append(UIBezierPath(roundedRect: rect, cornerRadius: MentionPill.cornerRadius)) + for rect in MentionPill.isDrawn ? mentionPillRects : [] { // LOCAL-TUNER + path.append(UIBezierPath(roundedRect: rect, cornerRadius: ChatStyle.mentionCapsule ? rect.height / 2 : MentionPill.cornerRadius)) // LOCAL-TUNER } + pills.fillColor = (isSent ? UIColor(ChatStyle.mentionPillFillSent) : MentionPill.fill).cgColor // LOCAL-TUNER pills.path = path.cgPath pills.frame = bounds } + // LOCAL-TUNER: the pill sized to the handle's own type, centred on its glyphs rather than filling + // the body's line, so a smaller handle gets a smaller pill. + private func fittedPill(_ line: CGRect, range: NSRange) -> CGRect { // LOCAL-TUNER + let wide = MentionPill.rect(around: line) // LOCAL-TUNER + guard let start = position(from: beginningOfDocument, offset: range.location + min(1, range.length)) else { return wide } // LOCAL-TUNER + let glyphs = caretRect(for: start) // LOCAL-TUNER + let height = glyphs.height + ChatStyle.mentionPaddingY * 2 // LOCAL-TUNER + return CGRect(x: wide.minX, y: glyphs.midY - height / 2, width: wide.width, height: height) // LOCAL-TUNER + } // LOCAL-TUNER + /// 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 [] } @@ -616,11 +644,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) } // LOCAL-TUNER 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)) // LOCAL-TUNER } return result } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift index 6f7074848..df0e49b03 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift @@ -26,7 +26,7 @@ import UIKit final class MessageBackdrop { /// How long the blur takes to fade in or out. - private static let fadeDuration: TimeInterval = 0.2 + private static var fadeDuration: TimeInterval { Tuner.value("Chat v2 Lift/Fade duration", default: 0.2, in: 0...1, unit: .seconds) } // LOCAL-TUNER /// Called when the held blur is tapped — the way out of an edit, as tapping outside the message /// is in WhatsApp. Never fires while a lift owns the screen: the lift's window sits above the @@ -67,6 +67,15 @@ final class MessageBackdrop { // available to animate. blurStrength = ChatBackdrop.frost(blur) + // LOCAL-TUNER: the dim from the first frame, under the menu too, so its slider reads at once. + let earlyDim = UIView(frame: blur.bounds) // LOCAL-TUNER + earlyDim.backgroundColor = .black // LOCAL-TUNER + earlyDim.alpha = ChatBackdrop.dimAlpha // LOCAL-TUNER + earlyDim.autoresizingMask = [.flexibleWidth, .flexibleHeight] // LOCAL-TUNER + earlyDim.isUserInteractionEnabled = false // LOCAL-TUNER + blur.contentView.addSubview(earlyDim) // LOCAL-TUNER + dim = earlyDim // LOCAL-TUNER + UIView.animate(withDuration: Self.fadeDuration) { blur.alpha = 1 } } @@ -91,9 +100,8 @@ final class MessageBackdrop { // 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) + let dim = self.dim ?? UIView(frame: blur.bounds) // LOCAL-TUNER 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 5f4ba58b1..cc25854b7 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ReactionPillRowView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ReactionPillRowView.swift @@ -397,7 +397,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 } @@ -421,7 +421,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) @@ -496,7 +496,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 @@ -578,9 +578,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.pillTrailingPadding } + private static var font: UIFont { .default(size: ChatStyle.pillCountSize, weight: .semibold) } private let label = UILabel() @@ -601,11 +601,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 @@ -625,15 +625,15 @@ private final class ReactionOverflowButton: UIControl { } } -/// The trailing "+" that opens the picker: the reaction strip's icon on a glass circle, at pill -/// height. Built from a glass effect view rather than `UIButton.Configuration.glass()`, whose platter -/// lays itself out larger than a 28pt frame. +/// The trailing "+" that opens the picker: the reaction strip's icon at pill height, 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(image: .asset(.addReaction)) + private let icon = UIImageView(image: .icon(.emojiAddReaction)) override init(frame: CGRect) { if #available(iOS 26, *) { @@ -649,6 +649,7 @@ private final class ReactionAddButton: UIControl { surface.isUserInteractionEnabled = false addSubview(surface) icon.contentMode = .scaleAspectFit + icon.tintColor = ChatStyle.secondaryText addSubview(icon) isAccessibilityElement = true accessibilityTraits = .button @@ -664,6 +665,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 cf2c5465f..170bb2f7a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ReactionPillView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ReactionPillView.swift @@ -10,8 +10,8 @@ import UIKit import SwiftUI import FlipcashCore -/// One reaction pill: emoji + count, capsule-shaped over the same wash a received bubble sits on. -/// A self-reacted pill draws a grey outline, which never changes its width. Tap toggles the reaction; the row it +/// One reaction pill: emoji + count on a capsule. A self-reacted pill takes the accent's tint, ring +/// and count colour; the ring never changes its width. 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 { @@ -30,37 +30,34 @@ 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) - /// Android's `thickBorder`. - private static let selfReactedBorderWidth: CGFloat = 2 + 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 } + + 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 = UIColor(Color.textMain) - countLabel.textAlignment = .center + countLabel.textAlignment = .left stack.addArrangedSubview(emojiLabel) 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, ]) @@ -82,12 +79,13 @@ 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)) // LOCAL-TUNER let fill = pill.selfReacted ? Self.selfReactedFill : Self.restingFill let borderWidth = pill.selfReacted ? Self.selfReactedBorderWidth : 0 @@ -104,6 +102,7 @@ final class ReactionPillView: UIView { } layer.borderColor = Self.selfReactedBorder.cgColor layer.borderWidth = borderWidth + countLabel.textColor = pill.selfReacted ? ChatStyle.accentColor : ChatStyle.secondaryText isUserInteractionEnabled = true isAccessibilityElement = true @@ -116,10 +115,25 @@ 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 + } /// 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/Theme/Colors.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift index 323161512..fb9644659 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift @@ -35,7 +35,7 @@ extension ShapeStyle where Self == Color { public static var bannerInfo: Color { Color("backgroundSecondary") } public static var bannerSuccess: Color { Color("backgroundSecondary") } - public static var backgroundMain: Color { Color("background") } + public static var backgroundMain: Color { Tuner.value("App/Background", default: Color(r: 0, g: 0, b: 0)) } // LOCAL-TUNER public static var backgroundSecondary: Color { Color("backgroundSecondary") } public static var backgroundRow: Color { Color.white.opacity(0.05) } public static var rowSeparator: Color { Color.white.opacity(0.1) } 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..1140df1be --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Icon.swift @@ -0,0 +1,145 @@ +// +// 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. + public static func icon(_ icon: Icon) -> Image { + Image(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..6ea359d28 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayAction.swift @@ -0,0 +1,166 @@ +// +// 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. 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. +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 { + HStack(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: + 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 + ) + } +} + +/// 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..b208080d9 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayAmountEntry.swift @@ -0,0 +1,77 @@ +// +// 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, + showChevron: false, + 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..cc180bbd7 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayAmountMetrics.swift @@ -0,0 +1,29 @@ +// +// 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. +/// +/// Every value is live in the tuner during the feel round and bakes back to a constant in Phase 2. +@MainActor +enum TrayAmountMetrics { + + /// The amount's size; the full-screen entry's is 74. + static var amountSize: CGFloat { points("Amount size", 60, 40...74) } // LOCAL-TUNER + /// The amount's line, in the full-screen entry's 90 : 74 proportion. + static var amountHeight: CGFloat { (amountSize * 90 / 74).rounded(.up) } + static var amountToCaption: CGFloat { points("Amount → caption", 4, 0...24) } // LOCAL-TUNER + static var captionSize: CGFloat { points("Caption size", 16, 11...20) } // LOCAL-TUNER + static var captionToKeypad: CGFloat { points("Caption → keypad", 24, 0...64) } // LOCAL-TUNER + /// How far the keypad reaches into the tray's side padding; the padding's 24 runs it edge to edge. + static var keypadSideBleed: CGFloat { points("Keypad side bleed", 16, 0...24) } // LOCAL-TUNER + + private static func points(_ name: String, _ value: CGFloat, _ range: ClosedRange) -> CGFloat { + Tuner.shared.value("Tray Amount/\(name)", default: value, in: range, unit: .points) // LOCAL-TUNER + } +} 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..3dc28d556 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayMetrics.swift @@ -0,0 +1,192 @@ +// +// 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. +/// +/// Every value is live in the tuner during the feel round and bakes back to a constant in Phase 2. +@MainActor +enum TrayMetrics { + + // MARK: - Layout + + static var padding: CGFloat { layout("Padding", 24, 0...48) } // LOCAL-TUNER + static var iconToTitle: CGFloat { layout("Icon → title", 12, 0...40) } // LOCAL-TUNER + static var titleToBody: CGFloat { layout("Title → body", 12, 0...32) } // LOCAL-TUNER + static var bodyToContent: CGFloat { layout("Body → content", 16, 0...40) } // LOCAL-TUNER + static var contentToActions: CGFloat { layout("Content → CTA", 24, 0...48) } // LOCAL-TUNER + static var iconSize: CGFloat { layout("Icon size", 64, 0...96) } // LOCAL-TUNER + static var closeSize: CGFloat { layout("Close size", 36, 24...48) } // LOCAL-TUNER + /// The drawn extent of the close X, edge to edge. + static var closeGlyphSize: CGFloat { layout("Close glyph", 12, 6...20) } // LOCAL-TUNER + static var closeStroke: CGFloat { layout("Close stroke", 2.5, 1...4) } // LOCAL-TUNER + static var closeGlyphOpacity: Double { Tuner.shared.value("Tray Layout/Close glyph opacity", default: 1.0, in: 0...1) } // LOCAL-TUNER + static var closeFillOpacity: Double { Tuner.shared.value("Tray Layout/Close fill opacity", default: 0.05, in: 0...0.3) } // LOCAL-TUNER + static var actionHeight: CGFloat { layout("CTA height", 54, 36...64) } // LOCAL-TUNER + static var actionGap: CGFloat { layout("CTA gap", 12, 0...24) } // LOCAL-TUNER + /// 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 var topRadius: CGFloat { layout("Top radius", 39.5, 0...60) } // LOCAL-TUNER + + /// 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 var titleSize: CGFloat { type("Title size", 24, 14...34) } // LOCAL-TUNER + /// A title in the header row, beside the X, for a step with no icon. + static var headerTitleSize: CGFloat { type("Header title size", 20, 14...28) } // LOCAL-TUNER + static var titleWeight: Font.Weight { Tuner.shared.value("Tray Type/Title weight", default: TrayTitleWeight.semibold).weight } // LOCAL-TUNER + static var titleTracking: CGFloat { type("Title tracking", -0.22, -2...2) } // LOCAL-TUNER + static var bodySize: CGFloat { type("Body size", 15, 10...20) } // LOCAL-TUNER + static var bodyLineHeight: CGFloat { type("Body line height", 20, 12...30) } // LOCAL-TUNER + static var bodyOpacity: Double { Tuner.shared.value("Tray Type/Body opacity", default: 0.6, in: 0...1) } // LOCAL-TUNER + static var bodyStyle: TrayBodyStyle { Tuner.shared.value("Tray Type/Body style", default: TrayBodyStyle.vibrant) } // LOCAL-TUNER + static var actionSize: CGFloat { type("CTA size", 16, 12...22) } // LOCAL-TUNER + + /// `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 var usesGlass: Bool { Tuner.shared.value("Tray Fill/Liquid Glass", default: false) } // LOCAL-TUNER + static var flatOpacity: Double { Tuner.shared.value("Tray Fill/Flat opacity", default: 0.5, in: 0...1) } // LOCAL-TUNER + /// Whether the system dims the screen behind the tray. + static var dimsBackground: Bool { Tuner.shared.value("Tray Fill/System dim", default: true) } // LOCAL-TUNER + + // MARK: - Rows + + static var rowHeight: CGFloat { Tuner.shared.value("Tray Rows/Height", default: 56, in: 40...72, unit: .points) } // LOCAL-TUNER + static var rowRadius: CGFloat { Tuner.shared.value("Tray Rows/Radius", default: 16, in: 0...36, unit: .points) } // LOCAL-TUNER + static var rowGap: CGFloat { Tuner.shared.value("Tray Rows/Gap", default: 8, in: 0...20, unit: .points) } // LOCAL-TUNER + static var rowFillOpacity: Double { Tuner.shared.value("Tray Rows/Fill opacity", default: 0.06, in: 0...0.3) } // LOCAL-TUNER + static var rowSelectedFillOpacity: Double { Tuner.shared.value("Tray Rows/Selected fill opacity", default: 0.14, in: 0...0.4) } // LOCAL-TUNER + static var rowIconSize: CGFloat { Tuner.shared.value("Tray Rows/Icon size", default: 22, in: 14...32, unit: .points) } // LOCAL-TUNER + static var rowTextSize: CGFloat { Tuner.shared.value("Tray Rows/Text size", default: 17, in: 13...22, unit: .points) } // LOCAL-TUNER + static var rowTextWeight: Font.Weight { Tuner.shared.value("Tray Rows/Text weight", default: TrayTitleWeight.semibold).weight } // LOCAL-TUNER + static var tileHeight: CGFloat { Tuner.shared.value("Tray Rows/Tile height", default: 64, in: 44...96, unit: .points) } // LOCAL-TUNER + static var tileRadius: CGFloat { Tuner.shared.value("Tray Rows/Tile radius", default: 16, in: 0...36, unit: .points) } // LOCAL-TUNER + static var tileSelection: TrayTileSelection { Tuner.shared.value("Tray Rows/Tile selection", default: TrayTileSelection.raised) } // LOCAL-TUNER + static var rowHaptic: TrayRowHaptic { Tuner.shared.value("Tray Rows/Haptic", default: TrayRowHaptic.selection) } // LOCAL-TUNER + static var rowPressScale: CGFloat { Tuner.shared.value("Tray Rows/Press scale", default: 0.98, in: 0.9...1) } // LOCAL-TUNER + + // MARK: - Error fade + + // The keys keep the "Tray Destructive" group the app's floating tuner lists. // LOCAL-TUNER + /// The red at the error card's top edge. + static var errorFadeStrength: Double { Tuner.shared.value("Tray Destructive/Fade strength", default: 0.39, in: 0...1) } // LOCAL-TUNER + /// How far down the error card the red reaches before it's gone, as a share of the card's height. + static var errorFadeLength: Double { Tuner.shared.value("Tray Destructive/Fade length", default: 0.865, in: 0.1...1) } // LOCAL-TUNER + /// How much darker the error card is than a standard tray, under the red. + static var errorFadeDarkness: Double { Tuner.shared.value("Tray Destructive/Fade card darkness", default: 0.53, in: 0...1) } // LOCAL-TUNER + + // MARK: - Behavior + + static var actionPress: TrayActionPress { Tuner.shared.value("Tray Behavior/CTA press", default: TrayActionPress.scale) } // LOCAL-TUNER + static var actionPressScale: CGFloat { Tuner.shared.value("Tray Behavior/CTA press scale", default: 0.95, in: 0.9...1) } // LOCAL-TUNER + static var actionPressDim: Double { Tuner.shared.value("Tray Behavior/CTA press dim", default: 0.7, in: 0.3...1) } // LOCAL-TUNER + + // MARK: - Tuner + + private static func layout(_ name: String, _ value: CGFloat, _ range: ClosedRange) -> CGFloat { + Tuner.shared.value("Tray Layout/\(name)", default: value, in: range, unit: .points) // LOCAL-TUNER + } + + private static func type(_ name: String, _ value: CGFloat, _ range: ClosedRange) -> CGFloat { + Tuner.shared.value("Tray Type/\(name)", default: value, in: range, unit: .points) // LOCAL-TUNER + } +} + +/// The title weights the tuner offers. +enum TrayTitleWeight: TunableEnum { // LOCAL-TUNER: conformance + case regular, medium, semibold, bold, heavy + + var weight: Font.Weight { + switch self { + case .regular: .regular + case .medium: .medium + case .semibold: .semibold + case .bold: .bold + case .heavy: .heavy + } + } +} + +/// The body text's ink. +enum TrayBodyStyle: TunableEnum { // LOCAL-TUNER: conformance + /// White at the body opacity. + case flat + /// White at the body opacity, blended plus-lighter so the card's colour shows through. + case vibrant + /// SwiftUI's `.secondary` hierarchical style. + case hierarchical +} + +/// How a selected tile stands out. +enum TrayTileSelection: TunableEnum { // LOCAL-TUNER: conformance + /// 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 +} + +/// The haptic a tray row plays on tap. +enum TrayRowHaptic: TunableEnum { // LOCAL-TUNER: conformance + case none, selection, softest, soft, medium + + /// Kept prepared between taps, so fast typing gets its haptic on time. + @MainActor private static let selectionGenerator = UISelectionFeedbackGenerator() + + @MainActor func play() { + switch self { + case .none: break + case .selection: + Self.selectionGenerator.selectionChanged() + Self.selectionGenerator.prepare() + case .softest: Haptics.softest() + case .soft: Haptics.soft() + case .medium: Haptics.medium() + } + } +} + +/// How the CTA answers a press. +enum TrayActionPress: TunableEnum { // LOCAL-TUNER: conformance + 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..18aa75e0c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayMotion.swift @@ -0,0 +1,136 @@ +// +// 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 var resize: ChatSpring { spring("Tray Motion/Resize", 0.35, 0) } // LOCAL-TUNER + + /// The incoming page fading and unblurring into place. + static var contentIn: ChatSpring { spring("Tray Motion/Content in", 0.27, 0) } // LOCAL-TUNER + + /// The outgoing page clearing; quicker than the entrance so the two never read as a muddle. + static var contentOut: ChatSpring { spring("Tray Motion/Content out", 0.20, 0) } // LOCAL-TUNER + + /// The action's old title clearing; quicker than the new one arriving. + static var labelOut: ChatSpring { spring("Tray Label/Out", 0.17, 0) } // LOCAL-TUNER + + /// The action's new title settling into place. + static var labelIn: ChatSpring { spring("Tray Label/In", 0.34, 0.1) } // LOCAL-TUNER + + /// How long the new title waits, so it lands as the old one clears. + static var labelInDelay: TimeInterval { + Tuner.value("Tray Label/In delay", default: 0.045, in: 0...0.2, unit: .seconds) // LOCAL-TUNER + } + + /// The blur a title carries at either end of its change. + static var labelBlur: CGFloat { + Tuner.value("Tray Label/Blur", default: 0, in: 0...10, unit: .points) // LOCAL-TUNER + } + + /// How far a title travels as it changes: the new one rises into place, the old one rises away. + static var labelRise: CGFloat { + Tuner.value("Tray Label/Rise", default: 0, in: 0...20, unit: .points) // LOCAL-TUNER + } + + /// The scale a title holds at either end of its change. + static var labelScale: CGFloat { + Tuner.value("Tray Label/Scale", default: 1, in: 0.8...1) // LOCAL-TUNER + } + + /// Whether a step back reverses the rise, so the title falls the way the flow went. + static var labelFollowsDirection: Bool { + Tuner.value("Tray Label/Follow direction", default: true) // LOCAL-TUNER + } + + /// How long the incoming page waits, so it lands as the outgoing one clears. + static var contentInDelay: TimeInterval { + Tuner.value("Tray Motion/Content in delay", default: 0.05, in: 0...0.3, unit: .seconds) // LOCAL-TUNER + } + + /// The blur a page carries at either end of its crossfade. + static var pageBlur: CGFloat { + Tuner.value("Tray Motion/Page blur", default: 2, in: 0...20, unit: .points) // LOCAL-TUNER + } + + /// Whether a page scales as it crossfades. + static var pageScales: Bool { + Tuner.value("Tray Motion/Page scale", default: true) // LOCAL-TUNER + } + + /// The scale a leaving page shrinks to. + static var pageScaleOut: CGFloat { + Tuner.value("Tray Motion/Scale out", default: 0.96, in: 0.85...1) // LOCAL-TUNER + } + + /// The scale an arriving page grows from. + static var pageScaleIn: CGFloat { + Tuner.value("Tray Motion/Scale in from", default: 0.96, in: 0.85...1) // LOCAL-TUNER + } + + // Bakes back to `ChatSpring(duration:bounce:)` lets. + private static func spring(_ key: String, _ duration: TimeInterval, _ bounce: Double) -> ChatSpring { + let spring = Tuner.value(key, default: Spring(duration: duration, bounce: bounce), form: "ChatSpring(duration: {duration}, bounce: {bounce})") // LOCAL-TUNER + return ChatSpring(duration: spring.duration, bounce: spring.bounce) + } +} + +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..b386cf338 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayOptionRow.swift @@ -0,0 +1,102 @@ +// +// TrayOptionRow.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI + +/// 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 { + + @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 { TrayMetrics.rowHaptic.play() } + } + } +} + +/// 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. // LOCAL-TUNER + @MainActor public static var gap: CGFloat { TrayMetrics.rowGap } // LOCAL-TUNER +} 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..4e3b632ac --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayPage.swift @@ -0,0 +1,99 @@ +// +// 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)) + .trayBodyStyle(TrayMetrics.bodyStyle) // LOCAL-TUNER + .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 + } +} + +extension View { + /// The body's ink: a flat colour, or vibrancy that lets the card's colour show through. // LOCAL-TUNER + @ViewBuilder // LOCAL-TUNER + func trayBodyStyle(_ style: TrayBodyStyle) -> some View { // LOCAL-TUNER + switch style { // LOCAL-TUNER + case .flat: // LOCAL-TUNER + foregroundStyle(Color.textMain.opacity(TrayMetrics.bodyOpacity)) // LOCAL-TUNER + case .vibrant: // LOCAL-TUNER + // Dark-mode vibrancy: light ink added onto what's beneath it. // LOCAL-TUNER + foregroundStyle(Color.white.opacity(TrayMetrics.bodyOpacity)).blendMode(.plusLighter) // LOCAL-TUNER + case .hierarchical: // LOCAL-TUNER + foregroundStyle(.secondary) // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER +} // LOCAL-TUNER diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayPresentation.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayPresentation.swift new file mode 100644 index 000000000..e22f214e6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayPresentation.swift @@ -0,0 +1,143 @@ +// +// 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 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 + .fixedSize(horizontal: false, vertical: true) + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + cardHeight = height + } + .environment(\.trayIsDismissable, isDismissable) + } + .ignoresSafeArea(.container, edges: Self.floatingEdges) + .background { + TraySheetSeam(cardHeight: cardHeight, dimsBackground: TrayMetrics.dimsBackground) + .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. +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.map(AnyShapeStyle.init) ?? AnyShapeStyle(.thinMaterial)) + .overlay { errorFade.clipShape(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..bd2b5943e --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TraySandbox.swift @@ -0,0 +1,683 @@ +// +// 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`, or the tuner's "Tray Behavior/Open tray + /// sandbox" is on, which holds across launches from the home screen. // LOCAL-TUNER + @MainActor public static var isRequested: Bool { + ProcessInfo.processInfo.arguments.contains("--tray-sandbox") + || Tuner.shared.value("Tray Behavior/Open tray sandbox", default: false) // LOCAL-TUNER + } + + /// 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) + } + } + // A release in these groups replays nothing rather than stepping the flow. + .onTunerReplay("Tray Layout") {} // LOCAL-TUNER + .onTunerReplay("Tray Type") {} // LOCAL-TUNER + .onTunerReplay("Tray Fill") {} // LOCAL-TUNER + .onTunerReplay("Tray Behavior") {} // LOCAL-TUNER + .onTunerReplay("Tray Rows") {} // LOCAL-TUNER + .onTunerReplay("Tray Amount") {} // LOCAL-TUNER + } + + 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 a tuner replay 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.") + } + } + // A motion release walks the flow forward to the end, then back. + .onTunerReplay("Tray Motion") { replayStep() } // LOCAL-TUNER + .onTunerReplay("Tray Label") { replayStep() } // LOCAL-TUNER + .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) + + // Below the keypad, where the tuner panel never docks. + 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..e9b7fd4b1 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TraySheetDriver.swift @@ -0,0 +1,231 @@ +// +// 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? + + /// 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? { + let height = shownHeight > 0 ? shownHeight : cardHeight + guard height > 0 else { return nil } + return min(height - systemBottomInset, context.maximumDetentValue) + } + + /// 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 + + func makeUIViewController(context: Context) -> TraySheetSeamController { + TraySheetSeamController() + } + + func updateUIViewController(_ controller: TraySheetSeamController, context: Context) { + 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..659b9060a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayStack.swift @@ -0,0 +1,232 @@ +// +// 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 + + 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() + .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") + } + + // 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. + 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. + 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/AmountField.swift b/FlipcashUI/Sources/FlipcashUI/Views/AmountField.swift index d89e7bbe4..f6b948891 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/AmountField.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/AmountField.swift @@ -89,8 +89,8 @@ public struct AmountField: View { ) if showChevron { - Image.system(.chevronDown) - .font(.default(size: 12, weight: .bold)) + Image.icon(.chevronDownMedium) + .iconSize(16) } } .animation(.springFastestDamped, value: content) 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..e89c1f2c4 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)) // LOCAL-TUNER .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..6cbe4469c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/ListRow.swift @@ -0,0 +1,219 @@ +// +// 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: String + private let subtitle: String? + 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 + + /// Creates a row; pass `EmptyView()` content for a slot the row doesn't use. + public init( + _ title: String, + subtitle: String? = nil, + tone: ListRowTone = .standard, + showsDivider: Bool = true, + action: @escaping () -> Void, + @ViewBuilder leading: () -> Leading, + @ViewBuilder trailing: () -> Trailing + ) { + self.title = title + self.subtitle = subtitle + 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) { + Text(title) + .foregroundStyle(titleColor) + .lineLimit(1) + .font(.default(size: Tuner.shared.value("Rows/Title size", default: CGFloat(16), in: 13...24, unit: .points), weight: [Font.Weight.regular, .medium, .semibold, .bold][Tuner.shared.value("Rows/Title weight (1 regular–4 bold)", default: 2, in: 1...4) - 1])) // LOCAL-TUNER + if let subtitle { + Text(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: Tuner.shared.value("Rows/Divider weight", default: CGFloat(1.0 / 3.0), in: 0...2, unit: .points)) // LOCAL-TUNER + .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. +@MainActor public enum ListRowMetrics { // LOCAL-TUNER + /// Screen-edge inset on both sides. + public static var margin: CGFloat { Tuner.shared.value("Rows/Side margin", default: CGFloat(20), in: 0...32, unit: .points) } // LOCAL-TUNER + /// The smallest a single-line row can be. + public static var minHeight: CGFloat { Tuner.shared.value("Rows/Min height", default: CGFloat(60), in: 32...88, unit: .points) } // LOCAL-TUNER + /// Space above and below the text. + public static var verticalPadding: CGFloat { Tuner.shared.value("Rows/Vertical padding", default: CGFloat(20), in: 0...28, unit: .points) } // LOCAL-TUNER + /// Width and height of the leading slot; the title starts after it. + public static var leadingSlot: CGFloat { Tuner.shared.value("Rows/Leading slot", default: CGFloat(30), in: 20...60, unit: .points) } // LOCAL-TUNER + /// Space between the leading slot, the text and the trailing accessory. + public static var gap: CGFloat { Tuner.shared.value("Rows/Gap", default: CGFloat(12), in: 0...28, unit: .points) } // LOCAL-TUNER + /// Space between the title and the subtitle. + public static var subtitleGap: CGFloat { Tuner.shared.value("Rows/Subtitle gap", default: CGFloat(2), in: 0...12, unit: .points) } // LOCAL-TUNER + /// How far before the trailing edge the divider stops. + public static var dividerTrailingInset: CGFloat { Tuner.shared.value("Rows/Divider right inset", default: CGFloat(20), in: 0...40, unit: .points) } // LOCAL-TUNER + /// The size of an icon in the leading slot. + public static var iconSize: CGFloat { Tuner.shared.value("Rows/Icon size", default: CGFloat(20), in: 12...36, unit: .points) } // LOCAL-TUNER + /// The opacity of an icon in the leading slot. + public static var iconOpacity: Double { Tuner.shared.value("Rows/Icon opacity", default: 0.5, in: 0...1) } // LOCAL-TUNER + /// The diameter of a flag in the leading slot. + public static var flagSize: CGFloat { Tuner.shared.value("Rows/Flag size", default: CGFloat(28), in: 16...48, unit: .points) } // LOCAL-TUNER + /// Space above a section title. + public static var sectionTop: CGFloat { Tuner.shared.value("Rows/Section top", default: CGFloat(24), in: 0...48, unit: .points) } // LOCAL-TUNER + /// Space between a section title and its first row. + public static var sectionBottom: CGFloat { Tuner.shared.value("Rows/Section bottom", default: CGFloat(0), in: 0...24, unit: .points) } // LOCAL-TUNER + /// The opacity of a section title. + public static var sectionOpacity: Double { Tuner.shared.value("Rows/Section title opacity", default: 0.5, in: 0...1) } // LOCAL-TUNER +} + +/// 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: String + + /// Creates a section title. + public init(_ title: String) { + 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) + } +} + +/// 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..02ea691c7 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift @@ -63,8 +63,8 @@ public struct SwipeControl: View { case .loading: LoadingView(color: .gray, style: .medium) case .success: - Image.asset(.checkmark) - .renderingMode(.template) + Image.icon(.checkmark1) + .iconSize(24) .foregroundStyle(.textMain) } } @@ -141,10 +141,8 @@ private struct SwipingTrack: View { .fill(.action) .frame(width: Layout.knobSize.width, height: Layout.knobSize.height) .overlay { - Image.system(.arrowRight) - .resizable() - .scaledToFit() - .frame(width: Layout.arrowSize, height: Layout.arrowSize) + Image.icon(.arrowRight) + .iconSize(Layout.arrowSize) .foregroundStyle(.textAction) } .offset(x: position + nudgeX) diff --git a/FlipcashUI/Sources/FlipcashUI/Views/KeyPadView.swift b/FlipcashUI/Sources/FlipcashUI/Views/KeyPadView.swift index bf0515127..47e2b920a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/KeyPadView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/KeyPadView.swift @@ -311,6 +311,14 @@ 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 + } + } } } @@ -372,7 +380,8 @@ private extension KeyPadView { } } label: { if let symbol = content.symbol { - Image(systemName: symbol.rawValue) + Image.icon(symbol.icon) + .iconSize(36) } else { Text(content.rawValue) } @@ -411,6 +420,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 +430,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 ? Tuner.shared.value("Amount entry/Key pressed scale", default: CGFloat(1.35), in: 1...2) : 1.0, anchor: .center) // LOCAL-TUNER .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(Color.white.opacity(configuration.isPressed ? Tuner.shared.value("Amount entry/Key pressed opacity", default: 1.0, in: 0...1) : Tuner.shared.value("Amount entry/Key rest opacity", default: 0.8, in: 0...1))) // LOCAL-TUNER .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 { From 6af57fef5a2ec40334be1402223e87120d734e75 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 17:41:09 +0000 Subject: [PATCH 02/42] feat(swipe): three tunable looks (glass thumb, thumb fills, lens) and a failure look Same gesture, thresholds, nudge and commit order as before; the commit rule moves into SwipeControl.shouldCommit unchanged. --- .../Views/Controls/SwipeControl.swift | 234 ++++++++++++------ .../Views/Controls/SwipeControlLook.swift | 159 ++++++++++++ .../FlipcashUI/Views/Controls/SwipeLens.metal | 38 +++ .../Views/Controls/SwipeMetrics.swift | 132 ++++++++++ 4 files changed, 491 insertions(+), 72 deletions(-) create mode 100644 FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeLens.metal create mode 100644 FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift index 02ea691c7..7a446d6b1 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift @@ -11,7 +11,8 @@ import FlipcashCore /// 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. A thrown error shows a +/// brief failure before the reset. /// /// The knob commits when dragged past 70% of the track, or past 50% with a fast /// release velocity. @@ -25,9 +26,12 @@ public struct SwipeControl: View { @Environment(\.displayScale) private var displayScale @Environment(\.isEnabled) private var isEnabled + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.swipeControlLook) private var lookOverride @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 @@ -41,37 +45,44 @@ 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 + let maxX = geometry.size.width - diameter - SwipingTrack(text: text, maxX: maxX, knobX: knobX, isNormal: state == .normal) { + SwipingTrack( + text: text, + look: lookOverride ?? SwipeMetrics.look, + diameter: diameter, + 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)) + .padding(SwipeMetrics.thumbInset) + .frame(height: SwipeMetrics.height) + .background { + Capsule() + .fill(trackFill) + .animation(.easeOut(duration: SwipeMetrics.failureTintFade), value: state == .failure) + } .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.icon(.checkmark1) - .iconSize(24) - .foregroundStyle(.textMain) + Capsule() + .strokeBorder(.textSecondary.opacity(SwipeMetrics.strokeOpacity), lineWidth: 1 / displayScale) + } + .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,69 +95,116 @@ public struct SwipeControl: View { } } + private var trackFill: Color { + switch state { + case .failure: + .bannerError.opacity(SwipeMetrics.failureTint) + case .normal, .loading, .success: + .action.opacity(SwipeMetrics.fillOpacity) + } + } + private func commit(maxX: CGFloat) async { state = .loading - knobX = maxX + Layout.knobSize.width * 2 + knobX = maxX do { try await action() state = .success try await completion?() try await Task.delay(seconds: 2) - } catch {} + } catch { + state = .failure + try? await Task.delay(milliseconds: Int(SwipeMetrics.failureHold * 1000)) + } 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 +/// thumb together so a drag (or nudge) re-renders only the track, not the +/// control's background, border, or failure tint. As the thumb advances it /// masks the label away from the left and fades the still-visible remainder /// toward transparent. private struct SwipingTrack: View { let text: String + let look: SwipeControlLook + let diameter: 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 + // Follows the touch one update after the drag's, whose transaction disables animations. + @State private var isLifted = false + @Namespace private var glassNamespace var body: some View { + let isNormal = state == .normal + let isTouching = dragOffset != nil let position = knobX + (dragOffset ?? 0) - let leadingX = position + Layout.knobSize.width - let labelOpacity = maxX > 0 ? 1 - min(max(position / maxX, 0), 1) : 1 - let nudgeX: CGFloat = (isNormal && isEnabled && dragOffset == nil && isNudging) ? Layout.nudgeOffset : 0 - - ZStack(alignment: .leading) { - Text(text) - .lineLimit(1) - .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) - } - .opacity(isNormal ? 1 : 0) - - RoundedRectangle(cornerRadius: Layout.knobCornerRadius) - .fill(.action) - .frame(width: Layout.knobSize.width, height: Layout.knobSize.height) - .overlay { - Image.icon(.arrowRight) - .iconSize(Layout.arrowSize) - .foregroundStyle(.textAction) + let nudgeX: CGFloat = (isNormal && isEnabled && dragOffset == nil && isNudging) ? SwipeMetrics.nudgeDistance : 0 + let fills = look == .morphFill && !isNormal + let snap = SwipeMetrics.snapBack.animation(reduceMotion: reduceMotion) + let nudge = SwipeMetrics.nudge.animation(reduceMotion: reduceMotion) + let press = SwipeMetrics.press.animation(reduceMotion: reduceMotion) + + let labelOpacity = (SwipeMetrics.labelFades && maxX > 0) ? 1 - min(max(position / maxX, 0), 1) : 1 + let wipeEdge = look == .lens ? SwipeMetrics.lensWipeEdge : SwipeMetrics.labelWipeEdge + let wipeX = SwipeMetrics.labelWipes ? position + diameter * wipeEdge : 0 + let liftScale = isLifted && !reduceMotion ? pressScale : 1 + + GlassContainer { + ZStack(alignment: .leading) { + Text(text) + .lineLimit(1) + .minimumScaleFactor(SwipeMetrics.labelMinimumScale) + .font(.appTextMedium) + .foregroundStyle(.textMain) + .padding(.horizontal, diameter) + .frame(maxWidth: .infinity, maxHeight: .infinity) // span the track so the mask and lens are in track coordinates + .modifier(SwipeLensEffect( + centerX: position + nudgeX + diameter / 2, + radius: diameter / 2 * liftScale, + bend: SwipeMetrics.lensBend, + band: SwipeMetrics.lensBand, + isEnabled: look == .lens && SwipeMetrics.usesGlass + )) + .animation(snap, value: position) + .animation(nudge, value: nudgeX) + .animation(press, value: isLifted) + .mask(alignment: .leading) { + Color.black + .opacity(labelOpacity) + .offset(x: wipeX) + .animation(snap, value: wipeX) + } + .opacity(isNormal ? 1 : 0) + + SwipeThumbSurface(look: look, isIdle: isNormal, glassNamespace: glassNamespace) { + thumbIcon } - .offset(x: position + nudgeX) - .animation(.springFastestDamped, value: position + nudgeX) + .animation(press) { $0.scaleEffect(liftScale) } + .opacity(isLifted && reduceMotion ? SwipeMetrics.pressDim : 1) + .frame(width: fills ? maxX + diameter : diameter, height: diameter) + .offset(x: fills ? 0 : position) + .animation(snap, value: position) + .animation(SwipeMetrics.morph.animation(reduceMotion: reduceMotion), value: fills) + .offset(x: nudgeX) + .animation(nudge, value: nudgeX) .gesture( DragGesture(minimumDistance: 0) .updating($dragOffset) { value, dragState, transaction in @@ -157,15 +215,20 @@ private struct SwipingTrack: View { 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 } + guard SwipeControl.shouldCommit(percent: percent, velocity: velocity) else { return } onCommit() } ) .disabled(!isNormal) + } } .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: isTouching) { _, touching in + isLifted = touching + } + .sensoryFeedback(.impact(flexibility: .soft, intensity: SwipeMetrics.touchHaptic), trigger: isTouching) { wasTouching, touching in + !wasTouching && touching + } .task { do { try await Task.delay(seconds: 3) @@ -180,16 +243,38 @@ private struct SwipingTrack: View { } } } -} -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 + private var pressScale: CGFloat { + switch look { + case .glassThumb, .morphFill: SwipeMetrics.pressScale + case .lens: SwipeMetrics.lensPressScale + } + } + // 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(.textMain) + case .success: + ButtonStateLabel(text, state: .success) + case .failure: + Image.icon(.crossMedium) + .iconSize(SwipeMetrics.arrowSize) + .foregroundStyle(.textMain) + } + } + .animation(nil, value: state) + } +} + +nonisolated private enum Layout { static let commitFraction: CGFloat = 0.7 static let velocityAssistFraction: CGFloat = 0.5 static let velocityAssistMinimum: CGFloat = 200 @@ -199,17 +284,22 @@ private enum KnobState { case normal case loading case success + case failure } #Preview { Background(color: .backgroundMain) { - VStack(spacing: 40) { - SwipeControl(text: "Swipe to Send") { - try await Task.delay(seconds: 2) + VStack(spacing: 32) { + ForEach(SwipeControlLook.allCases, id: \.self) { look in + SwipeControl(text: "Swipe to Send") { + try await Task.delay(seconds: 2) + } + .swipeControlLook(look) } - SwipeControl(text: "Swipe to Pay") { + SwipeControl(text: "Swipe to Fail") { try await Task.delay(seconds: 1) + throw CancellationError() } 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..193397699 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift @@ -0,0 +1,159 @@ +// +// SwipeControlLook.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI + +/// The ways ``SwipeControl`` can look; they share the track, the drag and every behaviour, and differ +/// in the thumb and in how a commit plays. +public enum SwipeControlLook: String, CaseIterable { + /// A glass thumb that parks at the end on commit and shows the progress in place. + case glassThumb + /// The glass thumb, which grows to fill the track on commit and shows the progress centred. + case morphFill + /// A see-through glass rim that bends the label as it passes; commits as `glassThumb` does. + case lens +} + +extension SwipeControlLook: TunableEnum {} // LOCAL-TUNER: conformance + +extension EnvironmentValues { + /// The look ``SwipeControl`` takes, or `nil` for the tuner's pick. + @Entry var swipeControlLook: SwipeControlLook? = nil +} + +public extension View { + /// Sets the look of every ``SwipeControl`` in this view; `nil` leaves the default. + func swipeControlLook(_ look: SwipeControlLook?) -> some View { + environment(\.swipeControlLook, look) + } +} + +// MARK: - Thumb surfaces + +/// The thumb's surface with `content` on it: Liquid Glass on iOS 26, a thin material with a +/// hairline on iOS 18. The lens look draws a see-through glass rim while idle. +struct SwipeThumbSurface: View { + + let look: SwipeControlLook + let isIdle: Bool + let glassNamespace: Namespace.ID + @ViewBuilder let content: Content + + @Environment(\.displayScale) private var displayScale + + var body: some View { + if #available(iOS 26, *) { + if SwipeMetrics.usesGlass { + glass + } else { + material + } + } else { + material + } + } + + private var face: some View { + content.frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @available(iOS 26, *) + @ViewBuilder + private var glass: some View { + switch look { + case .lens where isIdle: + face.background { lensRim } + case .glassThumb, .morphFill, .lens: + face + .glassEffect( + .regular + .tint(.white.opacity(SwipeMetrics.glassTint)) + .interactive(SwipeMetrics.glassInteractive), + in: .capsule + ) + .glassEffectID(glassID, in: glassNamespace) + } + } + + private var material: some View { + face + .background(.thinMaterial, in: .capsule) + .overlay { + Capsule() + .strokeBorder(.white.opacity(SwipeMetrics.fallbackHairline), lineWidth: 1 / displayScale) + } + } + + /// Clear glass masked to a soft ring, so the label reads through the middle. + @available(iOS 26, *) + private var lensRim: some View { + Circle() + .fill(.clear) + .glassEffect(.clear.tint(.white.opacity(SwipeMetrics.lensRimTint)), in: .circle) + .glassEffectID(glassID, in: glassNamespace) + .mask { + Circle() + .strokeBorder(.black, lineWidth: SwipeMetrics.lensRimWidth) + .blur(radius: SwipeMetrics.lensRimSoftness) + } + } + + private var glassID: String { "thumb" } +} + +// MARK: - Effects + +/// Bends the content under a circle at `centerX` with `radius`, through the `swipeLens` shader. +struct SwipeLensEffect: ViewModifier, Animatable { + + var centerX: CGFloat + var radius: CGFloat + let bend: CGFloat + let band: CGFloat + let isEnabled: Bool + + nonisolated var animatableData: AnimatablePair { + get { AnimatablePair(centerX, radius) } + set { + centerX = newValue.first + radius = newValue.second + } + } + + func body(content: Content) -> some View { + content.layerEffect( + ShaderLibrary.bundle(.module).swipeLens( + .boundingRect, + .float(centerX), + .float(radius), + .float(bend), + .float(radius * band) + ), + maxSampleOffset: CGSize(width: bend, height: bend), + isEnabled: isEnabled + ) + } +} + +/// 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..f39c46b39 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeLens.metal @@ -0,0 +1,38 @@ +// +// SwipeLens.metal +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#include +#include +using namespace metal; + +// The swipe control's lens: inside a circle, 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. +[[ stitchable ]] half4 swipeLens( + float2 position, + SwiftUI::Layer layer, + float4 bounds, + float centerX, + float radius, + float bend, + float band +) { + float2 center = float2(centerX, bounds.y + bounds.w * 0.5); + 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; + return layer.sample(position - outward * bend * pull * pull); +} diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift new file mode 100644 index 000000000..62e3888b9 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift @@ -0,0 +1,132 @@ +// +// SwipeMetrics.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI + +/// The swipe control's look and feel values: the track, the thumb, the lens, the failure beat and +/// every spring they move on. +/// +/// Every value is live in the tuner during the feel round and bakes back to a constant once a look +/// is picked. The commit thresholds are not here: they are behaviour, and stay in `SwipeControl`. +@MainActor +enum SwipeMetrics { + + // MARK: - Look + + /// The look every ``SwipeControl`` takes unless a view sets one with `swipeControlLook(_:)`. + static var look: SwipeControlLook { Tuner.shared.value("Swipe/Look", default: SwipeControlLook.morphFill) } // LOCAL-TUNER + + /// Draws the iOS 18 material thumb on iOS 26 too, so the fallback can be judged on a new phone. + static var forcesFallback: Bool { Tuner.shared.value("Swipe/iOS 18 fallback", default: false) } // LOCAL-TUNER + + /// Whether the thumb is Liquid Glass: iOS 26, and the fallback isn't being forced. + static var usesGlass: Bool { + if #available(iOS 26, *) { + !forcesFallback + } else { + false + } + } + + // MARK: - Track + + /// The capsule's height, shared with the tray's capsule buttons. + static var height: CGFloat { TrayMetrics.actionHeight } + static var thumbInset: CGFloat { layout("Thumb inset", 4, 0...12) } // LOCAL-TUNER + static var arrowSize: CGFloat { layout("Arrow size", 20, 12...32) } // LOCAL-TUNER + /// The track's `action` wash, as today's control draws it. + static var fillOpacity: Double { Tuner.shared.value("Swipe Layout/Fill opacity", default: 0.1, in: 0...0.3) } // LOCAL-TUNER + /// The hairline's `textSecondary` opacity; 0.3 is `Metrics.inputFieldStrokeColor(highlighted: false)`. + static var strokeOpacity: Double { Tuner.shared.value("Swipe Layout/Stroke opacity", default: 0.3, source: "Metrics.inputFieldStrokeColor(highlighted: false)", in: 0...1) } // LOCAL-TUNER + static var disabledOpacity: Double { Tuner.shared.value("Swipe Layout/Disabled opacity", default: 0.4, in: 0...1) } // LOCAL-TUNER + + // MARK: - Label + + /// How far the label may shrink to fit one line at large Dynamic Type sizes. + static var labelMinimumScale: CGFloat { Tuner.shared.value("Swipe Layout/Label min scale", default: 0.7, in: 0.5...1) } // LOCAL-TUNER + /// Whether the label is wiped away from the left as the thumb passes. + static var labelWipes: Bool { Tuner.shared.value("Swipe Layout/Label wipes", default: true) } // LOCAL-TUNER + /// Where across the thumb the wipe's edge sits, from its leading edge (0) to its trailing edge (1). + static var labelWipeEdge: CGFloat { Tuner.shared.value("Swipe Layout/Wipe edge", default: 1, in: 0...1) } // LOCAL-TUNER + /// Whether what's left of the label fades as the thumb travels. + static var labelFades: Bool { Tuner.shared.value("Swipe Layout/Label fades", default: true) } // LOCAL-TUNER + + // MARK: - Thumb + + /// The white tint over the thumb's glass. + static var glassTint: Double { Tuner.shared.value("Swipe Layout/Glass tint", default: 0, in: 0...0.3) } // LOCAL-TUNER + /// Whether the thumb's glass answers the finger the way system glass controls do. + static var glassInteractive: Bool { Tuner.shared.value("Swipe Layout/Glass interactive", default: true) } // LOCAL-TUNER + /// The hairline round the iOS 18 material thumb, as white opacity. + static var fallbackHairline: Double { Tuner.shared.value("Swipe Layout/Fallback hairline", default: 0.2, in: 0...1) } // LOCAL-TUNER + /// How far the idle nudge pushes the thumb right. + static var nudgeDistance: CGFloat { layout("Nudge distance", 20, 0...40) } // LOCAL-TUNER + + // MARK: - Press + + /// The thumb's scale under the finger, for the glass thumb and the morph. + static var pressScale: CGFloat { Tuner.shared.value("Swipe Motion/Press scale", default: 1.06, in: 1...1.3, unit: .multiplier) } // LOCAL-TUNER + /// The thumb's opacity under the finger with Reduce Motion on, standing in for the scale. + static var pressDim: Double { Tuner.shared.value("Swipe Motion/Press dim", default: 0.8, in: 0.3...1) } // LOCAL-TUNER + /// The soft tap's strength at touch-down. + static var touchHaptic: Double { Tuner.shared.value("Swipe Motion/Touch haptic", default: 0.5, in: 0...1) } // LOCAL-TUNER + + // MARK: - Motion + + /// The thumb settling: snapping back from a short drag, parking at the end, returning after a send. + static var snapBack: ChatSpring { spring("Swipe Motion/Snap back", 0.5, 0) } // LOCAL-TUNER + /// The thumb growing to fill the track on commit, in the morph look. + static var morph: ChatSpring { spring("Swipe Motion/Morph", 0.4, 0) } // LOCAL-TUNER + /// The thumb lifting under the finger and settling on release. + static var press: ChatSpring { spring("Swipe Motion/Press", 0.3, 0.25) } // LOCAL-TUNER + /// The idle nudge's push and return. + static var nudge: ChatSpring { spring("Swipe Motion/Nudge", 0.25, 0) } // LOCAL-TUNER + + // MARK: - Lens + + /// The lens thumb's scale under the finger; GSConfirm's 1.15 stays inside a 54 pt track here. + static var lensPressScale: CGFloat { Tuner.shared.value("Swipe Lens/Press scale", default: 1.12, in: 1...1.4, unit: .multiplier) } // LOCAL-TUNER + static var lensRimWidth: CGFloat { Tuner.shared.value("Swipe Lens/Rim width", default: 2, in: 0.5...8, unit: .points) } // LOCAL-TUNER + static var lensRimSoftness: CGFloat { Tuner.shared.value("Swipe Lens/Rim softness", default: 1.5, in: 0...6, unit: .points) } // LOCAL-TUNER + static var lensRimTint: Double { Tuner.shared.value("Swipe Lens/Rim tint", default: 0.1, in: 0...0.5) } // LOCAL-TUNER + /// How far the lens pulls the label at the rim. + static var lensBend: CGFloat { Tuner.shared.value("Swipe Lens/Bend", default: 12, in: 0...30, unit: .points) } // LOCAL-TUNER + /// How deep inside the rim the bend reaches, as a share of the lens's radius. + static var lensBand: CGFloat { Tuner.shared.value("Swipe Lens/Band", default: 0.5, in: 0.05...1) } // LOCAL-TUNER + /// The wipe's edge for the lens look, which shows the label through the thumb. + static var lensWipeEdge: CGFloat { Tuner.shared.value("Swipe Lens/Wipe edge", default: 0.5, in: 0...1) } // LOCAL-TUNER + + // MARK: - Failure + + static var shakeDistance: CGFloat { Tuner.shared.value("Swipe Failure/Shake distance", default: 8, in: 0...24, unit: .points) } // LOCAL-TUNER + static var shakeCount: Int { Tuner.shared.value("Swipe Failure/Shake count", default: 3, in: 1...8) } // LOCAL-TUNER + static var shakeDuration: TimeInterval { Tuner.shared.value("Swipe Failure/Shake duration", default: 0.4, in: 0.1...1, unit: .seconds) } // LOCAL-TUNER + /// The `bannerError` wash the track takes while it shows the failure. + static var failureTint: Double { Tuner.shared.value("Swipe Failure/Tint opacity", default: 0.35, in: 0...1) } // LOCAL-TUNER + static var failureTintFade: TimeInterval { Tuner.shared.value("Swipe Failure/Tint fade", default: 0.15, in: 0...0.5, unit: .seconds) } // LOCAL-TUNER + /// How long the failure shows before the control resets. + static var failureHold: TimeInterval { Tuner.shared.value("Swipe Failure/Hold", default: 0.6, in: 0...2, unit: .seconds) } // LOCAL-TUNER + + // MARK: - Tuner + + private static func layout(_ name: String, _ value: CGFloat, _ range: ClosedRange) -> CGFloat { + Tuner.shared.value("Swipe Layout/\(name)", default: value, in: range, unit: .points) // LOCAL-TUNER + } + + // Bakes back to `ChatSpring(duration:bounce:)` lets. + private static func spring(_ key: String, _ duration: TimeInterval, _ bounce: Double) -> ChatSpring { + let spring = Tuner.value(key, default: Spring(duration: duration, bounce: bounce), form: "ChatSpring(duration: {duration}, bounce: {bounce})") // LOCAL-TUNER + return ChatSpring(duration: spring.duration, bounce: spring.bounce) + } +} + +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 + } +} From 86a535773c08c7ffb5d9bc5b51a7029733091250 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 17:41:09 +0000 Subject: [PATCH 03/42] feat(swipe): Swipe Showcase in Beta Features and --swipe-sandbox --- Flipcash/Core/FlipcashApp.swift | 4 +- .../SettingsAdvancedBetaFeaturesScreen.swift | 10 ++ .../Views/Controls/SwipeShowcase.swift | 139 ++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift diff --git a/Flipcash/Core/FlipcashApp.swift b/Flipcash/Core/FlipcashApp.swift index 958213cfe..d3c86e76e 100644 --- a/Flipcash/Core/FlipcashApp.swift +++ b/Flipcash/Core/FlipcashApp.swift @@ -27,10 +27,12 @@ struct FlipcashApp: App { DialogSandbox().tunerFloating() // LOCAL-TUNER } else if TraySandbox.isRequested { // LOCAL-TUNER TraySandbox().tunerFloating() // LOCAL-TUNER + } else if SwipeSandbox.isRequested { // LOCAL-TUNER + SwipeShowcaseScreen().tunerFloating() // LOCAL-TUNER } else if let benchmark = ScrollBenchmark.requested { benchmark } else { - mainScene.tunerFloating(groups: ["Chat list", "Chat list Row", "Chat list Title", "Chat list Preview", "Chat list Date", "Chat list Unread", "Chat v2 Date", "Tray Layout", "Tray Type", "Tray Destructive", "Tray Rows", "Tray Amount", "Tray Mute", "Tray E2EE", "Tray Fill", "Tray Motion", "Tray Label", "Tray Behavior"]) // LOCAL-TUNER + mainScene.tunerFloating(groups: ["Chat list", "Chat list Row", "Chat list Title", "Chat list Preview", "Chat list Date", "Chat list Unread", "Chat v2 Date", "Tray Layout", "Tray Type", "Tray Destructive", "Tray Rows", "Tray Amount", "Tray Mute", "Tray E2EE", "Tray Fill", "Tray Motion", "Tray Label", "Tray Behavior", "Swipe", "Swipe Layout", "Swipe Motion", "Swipe Lens", "Swipe Failure"]) // LOCAL-TUNER } #else mainScene diff --git a/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift b/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift index 8446dcad5..ab1153f01 100644 --- a/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift @@ -30,6 +30,8 @@ struct SettingsAdvancedBetaFeaturesScreen: View { #if DEBUG // LOCAL-TUNER /// The tray the showcase has up over this screen. // LOCAL-TUNER @State private var showcaseTray: TrayKind? // LOCAL-TUNER + /// Whether the swipe showcase is up over this screen. // LOCAL-TUNER + @State private var isShowingSwipeShowcase = false // LOCAL-TUNER #endif // LOCAL-TUNER private let publicOptions = BetaFlags.Option.allCases.filter { $0.availability == .publicBeta } @@ -57,6 +59,9 @@ struct SettingsAdvancedBetaFeaturesScreen: View { unlinkRow(title: kind.name, isDisabled: false) { showcaseTray = kind } // LOCAL-TUNER .accessibilityIdentifier("tray-showcase-\(kind.rawValue)") // LOCAL-TUNER } // LOCAL-TUNER + SettingsSectionHeader("Swipe Showcase") // LOCAL-TUNER + unlinkRow(title: "Swipe to Send", isDisabled: false) { isShowingSwipeShowcase = true } // LOCAL-TUNER + .accessibilityIdentifier("swipe-showcase") // LOCAL-TUNER #endif // LOCAL-TUNER ForEach(publicOptions) { option in @@ -97,6 +102,11 @@ struct SettingsAdvancedBetaFeaturesScreen: View { .tray(item: $showcaseTray, isDismissable: showcaseTray?.isDismissable ?? true) { kind in // LOCAL-TUNER kind.tray // LOCAL-TUNER } // LOCAL-TUNER + // The swipe looks come up in a sheet, as the send screen does. // LOCAL-TUNER + .sheet(isPresented: $isShowingSwipeShowcase) { // LOCAL-TUNER + SwipeShowcaseScreen() // LOCAL-TUNER + .presentationDragIndicator(.visible) // LOCAL-TUNER + } // LOCAL-TUNER #endif // LOCAL-TUNER .alert( "Unlink Email?", diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift new file mode 100644 index 000000000..773ef7f57 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift @@ -0,0 +1,139 @@ +// +// 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`. // LOCAL-TUNER + @MainActor public static var isRequested: Bool { + ProcessInfo.processInfo.arguments.contains("--swipe-sandbox") // LOCAL-TUNER + } +} + +/// Every look of the swipe control side by side at the send screen's width, each driven by a +/// local closure that succeeds, fails or is disabled, so the looks can be felt without sending. +public struct SwipeShowcaseScreen: View { + + @State private var outcome: Outcome = .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) + looks + } + .padding(20) + } + .preferredColorScheme(.dark) + } + + // MARK: - Controls - + + private var controls: some View { + VStack(alignment: .leading, spacing: 16) { + Picker("Outcome", selection: $outcome) { + ForEach(Outcome.allCases) { outcome in + Text(outcome.title).tag(outcome) + } + } + .pickerStyle(.segmented) + + Toggle("Long label", isOn: $isLongLabel) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + + Text("Hold three fingers to open the tuner → Swipe") // LOCAL-TUNER + .font(.appTextCaption) // LOCAL-TUNER + .foregroundStyle(Color.textSecondary) // LOCAL-TUNER + } + } + + // MARK: - Looks - + + private var looks: some View { + VStack(alignment: .leading, spacing: 28) { + ForEach(SwipeControlLook.allCases, id: \.self) { look in // LOCAL-TUNER + VStack(alignment: .leading, spacing: 10) { + Text(Self.caption(for: look)) // LOCAL-TUNER + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + + SwipeControl(text: label, action: action) + .swipeControlLook(look) // LOCAL-TUNER + .disabled(outcome == .disabled) + } + } + } + } + + private var label: String { + isLongLabel ? "Swipe to Send $1,250.00 to Alexandra" : "Swipe to Send" + } + + private var action: ThrowingAction { + switch outcome { + case .succeeds, .disabled: + return { + try await Task.sleep(for: .seconds(1.5)) + } + case .fails: + return { + try await Task.sleep(for: .seconds(1)) + throw ShowcaseFailure() + } + } + } + + private static func caption(for look: SwipeControlLook) -> String { // LOCAL-TUNER + switch look { // LOCAL-TUNER + case .glassThumb: "A · Glass thumb" // LOCAL-TUNER + case .morphFill: "B · Thumb becomes the button" // LOCAL-TUNER + case .lens: "C · Lens" // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER +} + +// MARK: - Outcome - + +extension SwipeShowcaseScreen { + + /// What the showcase's swipe does once it commits. + private enum Outcome: String, CaseIterable, Identifiable { + case succeeds + case fails + case disabled + + var id: String { rawValue } + + var title: String { + switch self { + case .succeeds: "Succeeds" + case .fails: "Fails" + case .disabled: "Disabled" + } + } + } + + /// The error the "Fails" outcome throws. + private struct ShowcaseFailure: Error {} +} + +#Preview("Swipe showcase") { + SwipeShowcaseScreen() +} +#endif From a4138a5d5c0718e0ef3621758364a448a0ada7dd Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 17:47:03 +0000 Subject: [PATCH 04/42] test(swipe): pin the commit thresholds (70%, or 50% with a fling) --- FlipcashTests/Swipe/SwipeControlTests.swift | 38 +++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 FlipcashTests/Swipe/SwipeControlTests.swift 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)) + } +} From 1eb25d54930e209b472de2d56ef0eb01497c5a71 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 17:52:36 +0000 Subject: [PATCH 05/42] fix(swipe): review fixes; failure look is opt-in via SwipeControlFailure Callers' throws reset silently as before. One press feedback (interactive glass off by default), 0.4 s snap-back, Reduce Motion crossfades, shared glass helper, token colours. --- .../Views/Controls/SwipeControl.swift | 141 ++++++++++-------- .../Views/Controls/SwipeControlLook.swift | 56 +++---- .../Views/Controls/SwipeMetrics.swift | 16 +- .../Views/Controls/SwipeShowcase.swift | 5 +- 4 files changed, 111 insertions(+), 107 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift index 7a446d6b1..c33d8e2f8 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift @@ -9,10 +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. A thrown error shows a -/// brief failure before the reset. +/// 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. @@ -70,7 +78,7 @@ public struct SwipeControl: View { } .overlay { Capsule() - .strokeBorder(.textSecondary.opacity(SwipeMetrics.strokeOpacity), lineWidth: 1 / displayScale) + .strokeBorder(SwipeMetrics.strokeColor, lineWidth: 1 / displayScale) } .modifier(SwipeShake(phase: shakePhase, distance: SwipeMetrics.shakeDistance, count: SwipeMetrics.shakeCount)) .opacity(isEnabled ? 1 : SwipeMetrics.disabledOpacity) @@ -109,13 +117,20 @@ public struct SwipeControl: View { 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) - } catch { - state = .failure - try? await Task.delay(milliseconds: Int(SwipeMetrics.failureHold * 1000)) - } + } catch {} state = .normal knobX = 0 } @@ -156,7 +171,7 @@ private struct SwipingTrack: View { let isNormal = state == .normal let isTouching = dragOffset != nil let position = knobX + (dragOffset ?? 0) - let nudgeX: CGFloat = (isNormal && isEnabled && dragOffset == nil && isNudging) ? SwipeMetrics.nudgeDistance : 0 + let nudgeX: CGFloat = (isNormal && isEnabled && dragOffset == nil && isNudging && !reduceMotion) ? SwipeMetrics.nudgeDistance : 0 let fills = look == .morphFill && !isNormal let snap = SwipeMetrics.snapBack.animation(reduceMotion: reduceMotion) let nudge = SwipeMetrics.nudge.animation(reduceMotion: reduceMotion) @@ -167,61 +182,65 @@ private struct SwipingTrack: View { let wipeX = SwipeMetrics.labelWipes ? position + diameter * wipeEdge : 0 let liftScale = isLifted && !reduceMotion ? pressScale : 1 - GlassContainer { - ZStack(alignment: .leading) { - Text(text) - .lineLimit(1) - .minimumScaleFactor(SwipeMetrics.labelMinimumScale) - .font(.appTextMedium) - .foregroundStyle(.textMain) - .padding(.horizontal, diameter) - .frame(maxWidth: .infinity, maxHeight: .infinity) // span the track so the mask and lens are in track coordinates - .modifier(SwipeLensEffect( - centerX: position + nudgeX + diameter / 2, - radius: diameter / 2 * liftScale, - bend: SwipeMetrics.lensBend, - band: SwipeMetrics.lensBand, - isEnabled: look == .lens && SwipeMetrics.usesGlass - )) - .animation(snap, value: position) - .animation(nudge, value: nudgeX) - .animation(press, value: isLifted) - .mask(alignment: .leading) { - Color.black - .opacity(labelOpacity) - .offset(x: wipeX) - .animation(snap, value: wipeX) - } - .opacity(isNormal ? 1 : 0) - - SwipeThumbSurface(look: look, isIdle: isNormal, glassNamespace: glassNamespace) { - thumbIcon - } - .animation(press) { $0.scaleEffect(liftScale) } - .opacity(isLifted && reduceMotion ? SwipeMetrics.pressDim : 1) - .frame(width: fills ? maxX + diameter : diameter, height: diameter) - .offset(x: fills ? 0 : position) + ZStack(alignment: .leading) { + Text(text) + .lineLimit(1) + .minimumScaleFactor(SwipeMetrics.labelMinimumScale) + .font(.appTextMedium) + .foregroundStyle(.textMain) + .padding(.horizontal, diameter) + .frame(maxWidth: .infinity, maxHeight: .infinity) // span the track so the mask and lens are in track coordinates + .modifier(SwipeLensEffect( + centerX: position + nudgeX + diameter / 2, + radius: diameter / 2 * liftScale, + bend: SwipeMetrics.lensBend, + band: SwipeMetrics.lensBand, + isEnabled: look == .lens && SwipeMetrics.usesGlass + )) .animation(snap, value: position) - .animation(SwipeMetrics.morph.animation(reduceMotion: reduceMotion), value: fills) - .offset(x: nudgeX) .animation(nudge, value: 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 SwipeControl.shouldCommit(percent: percent, velocity: velocity) else { return } - onCommit() - } - ) - .disabled(!isNormal) + .animation(press, value: isLifted) + .mask(alignment: .leading) { + Color.black + .opacity(labelOpacity) + .offset(x: wipeX) + .animation(snap, value: wipeX) + } + .opacity(isNormal ? 1 : 0) + + SwipeThumbSurface(look: look, isIdle: isNormal, glassNamespace: glassNamespace) { + thumbIcon } + .animation(press) { $0.scaleEffect(liftScale) } + .opacity(isLifted && reduceMotion ? SwipeMetrics.pressDim : 1) + .frame(width: fills ? maxX + diameter : diameter, height: diameter) + .contentShape(.capsule) // the lens's see-through middle takes the touch too + .offset(x: fills ? 0 : position) + .animation(snap, value: position) + .animation(SwipeMetrics.morph.animation(reduceMotion: reduceMotion), value: fills) + .offset(x: nudgeX) + .animation(nudge, value: 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 SwipeControl.shouldCommit(percent: percent, velocity: velocity) else { return } + onCommit() + } + ) + .disabled(!isNormal) + // Reduce Motion swaps the thumb by identity at commit and reset, so the morph 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) .onChange(of: isTouching) { _, touching in isLifted = touching @@ -261,7 +280,7 @@ private struct SwipingTrack: View { .foregroundStyle(.textMain) case .loading: ButtonStateLabel(text, state: .loading) - .tint(.textMain) + .tint(.textSecondary) case .success: ButtonStateLabel(text, state: .success) case .failure: @@ -299,7 +318,7 @@ private enum KnobState { SwipeControl(text: "Swipe to Fail") { try await Task.delay(seconds: 1) - throw CancellationError() + 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 index 193397699..02b30888f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift @@ -34,8 +34,8 @@ public extension View { // MARK: - Thumb surfaces -/// The thumb's surface with `content` on it: Liquid Glass on iOS 26, a thin material with a -/// hairline on iOS 18. The lens look draws a see-through glass rim while idle. +/// The thumb's surface with `content` on it: the composer's glass (a tinted material before +/// iOS 26). The lens look draws a see-through glass rim while idle, with `content` above it. struct SwipeThumbSurface: View { let look: SwipeControlLook @@ -43,49 +43,39 @@ struct SwipeThumbSurface: View { let glassNamespace: Namespace.ID @ViewBuilder let content: Content - @Environment(\.displayScale) private var displayScale - var body: some View { - if #available(iOS 26, *) { - if SwipeMetrics.usesGlass { - glass - } else { - material + GlassContainer { + surface + } + // Outside the container, which composites its glass above sibling content. + .overlay { + if showsRim { + face } - } else { - material } } + private var showsRim: Bool { + look == .lens && isIdle && SwipeMetrics.usesGlass + } + private var face: some View { content.frame(maxWidth: .infinity, maxHeight: .infinity) } - @available(iOS 26, *) @ViewBuilder - private var glass: some View { - switch look { - case .lens where isIdle: - face.background { lensRim } - case .glassThumb, .morphFill, .lens: + private var surface: some View { + if SwipeMetrics.forcesFallback { // LOCAL-TUNER: draws `composerGlass`'s iOS 18 branch on iOS 26 face - .glassEffect( - .regular - .tint(.white.opacity(SwipeMetrics.glassTint)) - .interactive(SwipeMetrics.glassInteractive), - in: .capsule - ) - .glassEffectID(glassID, in: glassNamespace) - } - } - - private var material: some View { - face - .background(.thinMaterial, in: .capsule) - .overlay { - Capsule() - .strokeBorder(.white.opacity(SwipeMetrics.fallbackHairline), lineWidth: 1 / displayScale) + .background(Color.white.opacity(0.03), in: .capsule) // LOCAL-TUNER + .background(.ultraThinMaterial, in: .capsule) // LOCAL-TUNER + } else if showsRim { + if #available(iOS 26, *) { + lensRim } + } else { + face.composerGlass(in: Capsule(), id: glassID, namespace: glassNamespace, isInteractive: SwipeMetrics.glassInteractive) + } } /// Clear glass masked to a soft ring, so the label reads through the middle. diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift index 62e3888b9..4bd469672 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift @@ -40,8 +40,8 @@ enum SwipeMetrics { static var arrowSize: CGFloat { layout("Arrow size", 20, 12...32) } // LOCAL-TUNER /// The track's `action` wash, as today's control draws it. static var fillOpacity: Double { Tuner.shared.value("Swipe Layout/Fill opacity", default: 0.1, in: 0...0.3) } // LOCAL-TUNER - /// The hairline's `textSecondary` opacity; 0.3 is `Metrics.inputFieldStrokeColor(highlighted: false)`. - static var strokeOpacity: Double { Tuner.shared.value("Swipe Layout/Stroke opacity", default: 0.3, source: "Metrics.inputFieldStrokeColor(highlighted: false)", in: 0...1) } // LOCAL-TUNER + /// The track's hairline, the input field's resting stroke. + static var strokeColor: Color { Tuner.shared.value("Swipe Layout/Stroke", default: Metrics.inputFieldStrokeColor(highlighted: false), source: "Metrics.inputFieldStrokeColor(highlighted: false)") } // LOCAL-TUNER static var disabledOpacity: Double { Tuner.shared.value("Swipe Layout/Disabled opacity", default: 0.4, in: 0...1) } // LOCAL-TUNER // MARK: - Label @@ -57,12 +57,8 @@ enum SwipeMetrics { // MARK: - Thumb - /// The white tint over the thumb's glass. - static var glassTint: Double { Tuner.shared.value("Swipe Layout/Glass tint", default: 0, in: 0...0.3) } // LOCAL-TUNER - /// Whether the thumb's glass answers the finger the way system glass controls do. - static var glassInteractive: Bool { Tuner.shared.value("Swipe Layout/Glass interactive", default: true) } // LOCAL-TUNER - /// The hairline round the iOS 18 material thumb, as white opacity. - static var fallbackHairline: Double { Tuner.shared.value("Swipe Layout/Fallback hairline", default: 0.2, in: 0...1) } // LOCAL-TUNER + /// Whether the thumb's glass also swells under the finger, on top of the press scale. + static var glassInteractive: Bool { Tuner.shared.value("Swipe Layout/Glass interactive", default: false) } // LOCAL-TUNER /// How far the idle nudge pushes the thumb right. static var nudgeDistance: CGFloat { layout("Nudge distance", 20, 0...40) } // LOCAL-TUNER @@ -78,13 +74,15 @@ enum SwipeMetrics { // MARK: - Motion /// The thumb settling: snapping back from a short drag, parking at the end, returning after a send. - static var snapBack: ChatSpring { spring("Swipe Motion/Snap back", 0.5, 0) } // LOCAL-TUNER + static var snapBack: ChatSpring { spring("Swipe Motion/Snap back", 0.4, 0) } // LOCAL-TUNER /// The thumb growing to fill the track on commit, in the morph look. static var morph: ChatSpring { spring("Swipe Motion/Morph", 0.4, 0) } // LOCAL-TUNER /// The thumb lifting under the finger and settling on release. static var press: ChatSpring { spring("Swipe Motion/Press", 0.3, 0.25) } // LOCAL-TUNER /// The idle nudge's push and return. static var nudge: ChatSpring { spring("Swipe Motion/Nudge", 0.25, 0) } // LOCAL-TUNER + /// With Reduce Motion on, the crossfade that replaces the morph and the thumb's return after a send. + static var reduceMotionFade: TimeInterval { Tuner.shared.value("Swipe Motion/Reduce Motion fade", default: 0.2, in: 0...0.5, unit: .seconds) } // LOCAL-TUNER // MARK: - Lens diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift index 773ef7f57..e542aa691 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift @@ -94,7 +94,7 @@ public struct SwipeShowcaseScreen: View { case .fails: return { try await Task.sleep(for: .seconds(1)) - throw ShowcaseFailure() + throw SwipeControlFailure() } } } @@ -128,9 +128,6 @@ extension SwipeShowcaseScreen { } } } - - /// The error the "Fails" outcome throws. - private struct ShowcaseFailure: Error {} } #Preview("Swipe showcase") { From 44c4a333ea99417dafcac88689af272f26e63987 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 18:20:26 +0000 Subject: [PATCH 06/42] feat(swipe): bake Aiden's tuned values, default to the lens look, add the label shimmer The swipe gets its own 64 pt height; trays keep 54. --- .../Views/Controls/SwipeControl.swift | 5 ++ .../Views/Controls/SwipeControlLook.swift | 43 +++++++++++++++ .../Views/Controls/SwipeMetrics.swift | 52 +++++++++++++------ 3 files changed, 84 insertions(+), 16 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift index c33d8e2f8..10109371d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift @@ -188,6 +188,11 @@ private struct SwipingTrack: View { .minimumScaleFactor(SwipeMetrics.labelMinimumScale) .font(.appTextMedium) .foregroundStyle(.textMain) + // Before the lens, so the band bends with the label. + .modifier(SwipeShimmer( + isOn: SwipeMetrics.shimmers && !reduceMotion, + isRunning: isNormal && isEnabled && !isTouching + )) .padding(.horizontal, diameter) .frame(maxWidth: .infinity, maxHeight: .infinity) // span the track so the mask and lens are in track coordinates .modifier(SwipeLensEffect( diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift index 02b30888f..13fffa83c 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift @@ -129,6 +129,49 @@ struct SwipeLensEffect: ViewModifier, Animatable { } } +/// 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 ? SwipeMetrics.shimmerBandOpacity : 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.shimmerScalesWithLabel ? SwipeMetrics.shimmerPeriod : travel / SwipeMetrics.shimmerSpeed + + // 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 = period > 0 ? elapsed.truncatingRemainder(dividingBy: period) / period : 0 + + 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 { diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift index 4bd469672..9959e1b1d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift @@ -18,7 +18,7 @@ enum SwipeMetrics { // MARK: - Look /// The look every ``SwipeControl`` takes unless a view sets one with `swipeControlLook(_:)`. - static var look: SwipeControlLook { Tuner.shared.value("Swipe/Look", default: SwipeControlLook.morphFill) } // LOCAL-TUNER + static var look: SwipeControlLook { Tuner.shared.value("Swipe/Look", default: SwipeControlLook.lens) } // LOCAL-TUNER /// Draws the iOS 18 material thumb on iOS 26 too, so the fallback can be judged on a new phone. static var forcesFallback: Bool { Tuner.shared.value("Swipe/iOS 18 fallback", default: false) } // LOCAL-TUNER @@ -34,10 +34,10 @@ enum SwipeMetrics { // MARK: - Track - /// The capsule's height, shared with the tray's capsule buttons. - static var height: CGFloat { TrayMetrics.actionHeight } - static var thumbInset: CGFloat { layout("Thumb inset", 4, 0...12) } // LOCAL-TUNER - static var arrowSize: CGFloat { layout("Arrow size", 20, 12...32) } // LOCAL-TUNER + /// The capsule's height, which stands taller than the tray's capsule buttons. + static var height: CGFloat { layout("Height", 64, 36...80) } // LOCAL-TUNER + static var thumbInset: CGFloat { layout("Thumb inset", 6, 0...12) } // LOCAL-TUNER + static var arrowSize: CGFloat { layout("Arrow size", 24, 12...32) } // LOCAL-TUNER /// The track's `action` wash, as today's control draws it. static var fillOpacity: Double { Tuner.shared.value("Swipe Layout/Fill opacity", default: 0.1, in: 0...0.3) } // LOCAL-TUNER /// The track's hairline, the input field's resting stroke. @@ -49,9 +49,9 @@ enum SwipeMetrics { /// How far the label may shrink to fit one line at large Dynamic Type sizes. static var labelMinimumScale: CGFloat { Tuner.shared.value("Swipe Layout/Label min scale", default: 0.7, in: 0.5...1) } // LOCAL-TUNER /// Whether the label is wiped away from the left as the thumb passes. - static var labelWipes: Bool { Tuner.shared.value("Swipe Layout/Label wipes", default: true) } // LOCAL-TUNER + static var labelWipes: Bool { Tuner.shared.value("Swipe Layout/Label wipes", default: false) } // LOCAL-TUNER /// Where across the thumb the wipe's edge sits, from its leading edge (0) to its trailing edge (1). - static var labelWipeEdge: CGFloat { Tuner.shared.value("Swipe Layout/Wipe edge", default: 1, in: 0...1) } // LOCAL-TUNER + static var labelWipeEdge: CGFloat { Tuner.shared.value("Swipe Layout/Wipe edge", default: 0.29, in: 0...1) } // LOCAL-TUNER /// Whether what's left of the label fades as the thumb travels. static var labelFades: Bool { Tuner.shared.value("Swipe Layout/Label fades", default: true) } // LOCAL-TUNER @@ -60,7 +60,7 @@ enum SwipeMetrics { /// Whether the thumb's glass also swells under the finger, on top of the press scale. static var glassInteractive: Bool { Tuner.shared.value("Swipe Layout/Glass interactive", default: false) } // LOCAL-TUNER /// How far the idle nudge pushes the thumb right. - static var nudgeDistance: CGFloat { layout("Nudge distance", 20, 0...40) } // LOCAL-TUNER + static var nudgeDistance: CGFloat { layout("Nudge distance", 16, 0...40) } // LOCAL-TUNER // MARK: - Press @@ -86,17 +86,37 @@ enum SwipeMetrics { // MARK: - Lens - /// The lens thumb's scale under the finger; GSConfirm's 1.15 stays inside a 54 pt track here. - static var lensPressScale: CGFloat { Tuner.shared.value("Swipe Lens/Press scale", default: 1.12, in: 1...1.4, unit: .multiplier) } // LOCAL-TUNER - static var lensRimWidth: CGFloat { Tuner.shared.value("Swipe Lens/Rim width", default: 2, in: 0.5...8, unit: .points) } // LOCAL-TUNER - static var lensRimSoftness: CGFloat { Tuner.shared.value("Swipe Lens/Rim softness", default: 1.5, in: 0...6, unit: .points) } // LOCAL-TUNER - static var lensRimTint: Double { Tuner.shared.value("Swipe Lens/Rim tint", default: 0.1, in: 0...0.5) } // LOCAL-TUNER + /// The lens thumb's scale under the finger, which lets it swell past the track's edge. + static var lensPressScale: CGFloat { Tuner.shared.value("Swipe Lens/Press scale", default: 1.4, in: 1...1.6, unit: .multiplier) } // LOCAL-TUNER + static var lensRimWidth: CGFloat { Tuner.shared.value("Swipe Lens/Rim width", default: 0.5, in: 0...8, unit: .points) } // LOCAL-TUNER + static var lensRimSoftness: CGFloat { Tuner.shared.value("Swipe Lens/Rim softness", default: 0, in: 0...6, unit: .points) } // LOCAL-TUNER + static var lensRimTint: Double { Tuner.shared.value("Swipe Lens/Rim tint", default: 0, in: 0...0.5) } // LOCAL-TUNER /// How far the lens pulls the label at the rim. - static var lensBend: CGFloat { Tuner.shared.value("Swipe Lens/Bend", default: 12, in: 0...30, unit: .points) } // LOCAL-TUNER + static var lensBend: CGFloat { Tuner.shared.value("Swipe Lens/Bend", default: 6.1, in: 0...30, unit: .points) } // LOCAL-TUNER /// How deep inside the rim the bend reaches, as a share of the lens's radius. - static var lensBand: CGFloat { Tuner.shared.value("Swipe Lens/Band", default: 0.5, in: 0.05...1) } // LOCAL-TUNER + static var lensBand: CGFloat { Tuner.shared.value("Swipe Lens/Band", default: 0.73, in: 0.05...1) } // LOCAL-TUNER /// The wipe's edge for the lens look, which shows the label through the thumb. - static var lensWipeEdge: CGFloat { Tuner.shared.value("Swipe Lens/Wipe edge", default: 0.5, in: 0...1) } // LOCAL-TUNER + static var lensWipeEdge: CGFloat { Tuner.shared.value("Swipe Lens/Wipe edge", default: 0.42, in: 0...1) } // LOCAL-TUNER + + // MARK: - Shimmer + + /// Whether a bright band sweeps the label while the control waits for a swipe. + static var shimmers: Bool { Tuner.shared.value("Swipe Shimmer/On", default: true) } // LOCAL-TUNER + static var shimmerBandWidth: CGFloat { Tuner.shared.value("Swipe Shimmer/Band width", default: 30, in: 4...120, unit: .points) } // LOCAL-TUNER + static var shimmerAngle: Double { Tuner.shared.value("Swipe Shimmer/Angle", default: 15, in: -60...60, unit: .degrees) } // LOCAL-TUNER + static var shimmerBlur: CGFloat { Tuner.shared.value("Swipe Shimmer/Blur", default: 5, in: 0...20, unit: .points) } // LOCAL-TUNER + /// The band's opacity: how bright the label reads where the band passes. + static var shimmerBandOpacity: Double { Tuner.shared.value("Swipe Shimmer/Band opacity", default: 1, in: 0...1) } // LOCAL-TUNER + /// The label's opacity outside the band, which leaves the band room to read brighter. + static var shimmerLabelOpacity: Double { Tuner.shared.value("Swipe Shimmer/Label opacity", default: 0.6, in: 0...1) } // LOCAL-TUNER + /// Whether every label takes one period to sweep, so longer labels sweep faster, as GSConfirm does. + static var shimmerScalesWithLabel: Bool { Tuner.shared.value("Swipe Shimmer/Scales with label", default: true) } // LOCAL-TUNER + /// The time one sweep takes when it scales with the label. + static var shimmerPeriod: TimeInterval { Tuner.shared.value("Swipe Shimmer/Period", default: 3, in: 0.5...8, unit: .seconds) } // LOCAL-TUNER + /// The band's speed in points a second when it doesn't scale with the label. + static var shimmerSpeed: Double { Tuner.shared.value("Swipe Shimmer/Speed", default: 71, in: 10...300) } // LOCAL-TUNER + /// The band fading away when the sweep pauses, and back when it resumes. + static var shimmerFade: TimeInterval { Tuner.shared.value("Swipe Shimmer/Fade", default: 0.2, in: 0...0.6, unit: .seconds) } // LOCAL-TUNER // MARK: - Failure From 6cae53f86a91e64312c41b3f47cb5306dde9cd3f Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 18:20:26 +0000 Subject: [PATCH 07/42] feat(swipe): amount-flow page in the Swipe Showcase --- Flipcash/Core/FlipcashApp.swift | 4 +- .../SettingsAdvancedBetaFeaturesScreen.swift | 4 +- .../Screens/Settings/SwipeFlowShowcase.swift | 146 ++++++++++++++++++ .../Views/Controls/SwipeShowcase.swift | 70 +++++---- 4 files changed, 190 insertions(+), 34 deletions(-) create mode 100644 Flipcash/Core/Screens/Settings/SwipeFlowShowcase.swift diff --git a/Flipcash/Core/FlipcashApp.swift b/Flipcash/Core/FlipcashApp.swift index d3c86e76e..7f6eb0de3 100644 --- a/Flipcash/Core/FlipcashApp.swift +++ b/Flipcash/Core/FlipcashApp.swift @@ -28,11 +28,11 @@ struct FlipcashApp: App { } else if TraySandbox.isRequested { // LOCAL-TUNER TraySandbox().tunerFloating() // LOCAL-TUNER } else if SwipeSandbox.isRequested { // LOCAL-TUNER - SwipeShowcaseScreen().tunerFloating() // LOCAL-TUNER + SwipeShowcaseHost().tunerFloating() // LOCAL-TUNER } else if let benchmark = ScrollBenchmark.requested { benchmark } else { - mainScene.tunerFloating(groups: ["Chat list", "Chat list Row", "Chat list Title", "Chat list Preview", "Chat list Date", "Chat list Unread", "Chat v2 Date", "Tray Layout", "Tray Type", "Tray Destructive", "Tray Rows", "Tray Amount", "Tray Mute", "Tray E2EE", "Tray Fill", "Tray Motion", "Tray Label", "Tray Behavior", "Swipe", "Swipe Layout", "Swipe Motion", "Swipe Lens", "Swipe Failure"]) // LOCAL-TUNER + mainScene.tunerFloating(groups: ["Chat list", "Chat list Row", "Chat list Title", "Chat list Preview", "Chat list Date", "Chat list Unread", "Chat v2 Date", "Tray Layout", "Tray Type", "Tray Destructive", "Tray Rows", "Tray Amount", "Tray Mute", "Tray E2EE", "Tray Fill", "Tray Motion", "Tray Label", "Tray Behavior", "Swipe", "Swipe Layout", "Swipe Motion", "Swipe Lens", "Swipe Failure", "Swipe Shimmer"]) // LOCAL-TUNER } #else mainScene diff --git a/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift b/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift index ab1153f01..4c4e6c2ab 100644 --- a/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift @@ -102,9 +102,9 @@ struct SettingsAdvancedBetaFeaturesScreen: View { .tray(item: $showcaseTray, isDismissable: showcaseTray?.isDismissable ?? true) { kind in // LOCAL-TUNER kind.tray // LOCAL-TUNER } // LOCAL-TUNER - // The swipe looks come up in a sheet, as the send screen does. // LOCAL-TUNER + // The swipe showcase comes up in a sheet, as the send screen does. // LOCAL-TUNER .sheet(isPresented: $isShowingSwipeShowcase) { // LOCAL-TUNER - SwipeShowcaseScreen() // LOCAL-TUNER + SwipeShowcaseHost() // LOCAL-TUNER .presentationDragIndicator(.visible) // LOCAL-TUNER } // LOCAL-TUNER #endif // LOCAL-TUNER diff --git a/Flipcash/Core/Screens/Settings/SwipeFlowShowcase.swift b/Flipcash/Core/Screens/Settings/SwipeFlowShowcase.swift new file mode 100644 index 000000000..c449a750d --- /dev/null +++ b/Flipcash/Core/Screens/Settings/SwipeFlowShowcase.swift @@ -0,0 +1,146 @@ +// +// SwipeFlowShowcase.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if DEBUG +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// The swipe showcase's root: the looks side by side, 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 looks show. +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 + @State private var look: SwipeControlLook = .lens // LOCAL-TUNER + + /// 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) + .swipeControlLook(look) // LOCAL-TUNER + } + .foregroundStyle(.textMain) + .padding(20) + } + .ignoresSafeArea(.keyboard) + } + + // MARK: - Controls - + + private var controls: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + Picker("Outcome", selection: $outcome) { + ForEach(SwipeShowcaseOutcome.allCases) { outcome in + Text(outcome.title).tag(outcome) + } + } + .pickerStyle(.segmented) + + Picker("Look", selection: $look) { // LOCAL-TUNER + ForEach(SwipeControlLook.allCases, id: \.self) { look in // LOCAL-TUNER + Text(look.showcaseLetter).tag(look) // LOCAL-TUNER + } // LOCAL-TUNER + } // LOCAL-TUNER + .pickerStyle(.segmented) // LOCAL-TUNER + .frame(width: 120) // LOCAL-TUNER + } + + Text("\(look.showcaseCaption) · Hold three fingers to open the tuner → Swipe") // LOCAL-TUNER + .font(.appTextCaption) // LOCAL-TUNER + .foregroundStyle(Color.textSecondary) // LOCAL-TUNER + } + } +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift index e542aa691..3b76d2f68 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift @@ -24,7 +24,7 @@ public enum SwipeSandbox { /// local closure that succeeds, fails or is disabled, so the looks can be felt without sending. public struct SwipeShowcaseScreen: View { - @State private var outcome: Outcome = .succeeds + @State private var outcome: SwipeShowcaseOutcome = .succeeds @State private var isLongLabel = false /// Creates the showcase. @@ -47,7 +47,7 @@ public struct SwipeShowcaseScreen: View { private var controls: some View { VStack(alignment: .leading, spacing: 16) { Picker("Outcome", selection: $outcome) { - ForEach(Outcome.allCases) { outcome in + ForEach(SwipeShowcaseOutcome.allCases) { outcome in Text(outcome.title).tag(outcome) } } @@ -69,11 +69,11 @@ public struct SwipeShowcaseScreen: View { VStack(alignment: .leading, spacing: 28) { ForEach(SwipeControlLook.allCases, id: \.self) { look in // LOCAL-TUNER VStack(alignment: .leading, spacing: 10) { - Text(Self.caption(for: look)) // LOCAL-TUNER + Text(look.showcaseCaption) // LOCAL-TUNER .font(.appTextCaption) .foregroundStyle(Color.textSecondary) - SwipeControl(text: label, action: action) + SwipeControl(text: label, action: outcome.action) .swipeControlLook(look) // LOCAL-TUNER .disabled(outcome == .disabled) } @@ -84,9 +84,30 @@ public struct SwipeShowcaseScreen: View { private var label: String { isLongLabel ? "Swipe to Send $1,250.00 to Alexandra" : "Swipe to Send" } +} + +// MARK: - Outcome - - private var action: ThrowingAction { - switch 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)) @@ -98,37 +119,26 @@ public struct SwipeShowcaseScreen: View { } } } +} + +// MARK: - Look captions - + +extension SwipeControlLook { // LOCAL-TUNER - private static func caption(for look: SwipeControlLook) -> String { // LOCAL-TUNER - switch look { // LOCAL-TUNER + /// The look's letter and name, e.g. "C · Lens". // LOCAL-TUNER + public var showcaseCaption: String { // LOCAL-TUNER + switch self { // LOCAL-TUNER case .glassThumb: "A · Glass thumb" // LOCAL-TUNER case .morphFill: "B · Thumb becomes the button" // LOCAL-TUNER case .lens: "C · Lens" // LOCAL-TUNER } // LOCAL-TUNER } // LOCAL-TUNER -} - -// MARK: - Outcome - - -extension SwipeShowcaseScreen { - - /// What the showcase's swipe does once it commits. - private enum Outcome: String, CaseIterable, Identifiable { - case succeeds - case fails - case disabled - - var id: String { rawValue } - var title: String { - switch self { - case .succeeds: "Succeeds" - case .fails: "Fails" - case .disabled: "Disabled" - } - } - } -} + /// The look's letter alone, for a compact picker. // LOCAL-TUNER + public var showcaseLetter: String { // LOCAL-TUNER + String(showcaseCaption.prefix(1)) // LOCAL-TUNER + } // LOCAL-TUNER +} // LOCAL-TUNER #Preview("Swipe showcase") { SwipeShowcaseScreen() From b65cfa738951c469eda1927ee8edf3fa433a6b88 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 18:28:31 +0000 Subject: [PATCH 08/42] feat(swipe): sit the swipe in the Next button's slot on amount entry Same side and bottom insets as the amount-entry button; layout only. --- Flipcash/Core/FlipcashApp.swift | 2 +- Flipcash/UI/EnterAmountView.swift | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Flipcash/Core/FlipcashApp.swift b/Flipcash/Core/FlipcashApp.swift index 7f6eb0de3..5099c0572 100644 --- a/Flipcash/Core/FlipcashApp.swift +++ b/Flipcash/Core/FlipcashApp.swift @@ -32,7 +32,7 @@ struct FlipcashApp: App { } else if let benchmark = ScrollBenchmark.requested { benchmark } else { - mainScene.tunerFloating(groups: ["Chat list", "Chat list Row", "Chat list Title", "Chat list Preview", "Chat list Date", "Chat list Unread", "Chat v2 Date", "Tray Layout", "Tray Type", "Tray Destructive", "Tray Rows", "Tray Amount", "Tray Mute", "Tray E2EE", "Tray Fill", "Tray Motion", "Tray Label", "Tray Behavior", "Swipe", "Swipe Layout", "Swipe Motion", "Swipe Lens", "Swipe Failure", "Swipe Shimmer"]) // LOCAL-TUNER + mainScene.tunerFloating(groups: ["Chat list", "Chat list Row", "Chat list Title", "Chat list Preview", "Chat list Date", "Chat list Unread", "Chat v2 Date", "Tray Layout", "Tray Type", "Tray Destructive", "Tray Rows", "Tray Amount", "Tray Mute", "Tray E2EE", "Tray Fill", "Tray Motion", "Tray Label", "Tray Behavior", "Swipe", "Swipe Layout", "Swipe Motion", "Swipe Lens", "Swipe Failure", "Swipe Shimmer", "Amount entry"]) // LOCAL-TUNER } #else mainScene diff --git a/Flipcash/UI/EnterAmountView.swift b/Flipcash/UI/EnterAmountView.swift index 3aa31ffb2..c80c1f732 100644 --- a/Flipcash/UI/EnterAmountView.swift +++ b/Flipcash/UI/EnterAmountView.swift @@ -198,7 +198,10 @@ public struct EnterAmountView: View { 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, Tuner.shared.value("Amount entry/Keypad to swipe", default: CGFloat(20), in: 0...48, unit: .points)) // LOCAL-TUNER + .padding(.horizontal, Tuner.shared.value("Amount entry/Button side inset", default: CGFloat(32), in: 16...56, unit: .points) - 20) // LOCAL-TUNER + .padding(.bottom, Tuner.shared.value("Amount entry/Button bottom inset", default: CGFloat(32), in: 0...80, unit: .points) - 54) // LOCAL-TUNER } else { Button(action: action) { ButtonStateLabel(actionTitle ?? mode.actionName, state: actionState) From cbb21538251a4c83ee39e490f29420ce9f6ff718 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 18:40:38 +0000 Subject: [PATCH 09/42] feat(swipe): track stroke, colour and handle width props; dim the amount screen with swipe travel Progress reaches EnterAmountView through SwipeControlProgressKey; visual only. --- Flipcash/Core/FlipcashApp.swift | 2 +- Flipcash/UI/EnterAmountView.swift | 85 ++++++++++++++----- .../Views/Controls/SwipeControl.swift | 58 +++++++++++-- .../Views/Controls/SwipeControlLook.swift | 21 +++-- .../FlipcashUI/Views/Controls/SwipeLens.metal | 8 +- .../Views/Controls/SwipeMetrics.swift | 6 ++ 6 files changed, 141 insertions(+), 39 deletions(-) diff --git a/Flipcash/Core/FlipcashApp.swift b/Flipcash/Core/FlipcashApp.swift index 5099c0572..ca242f8ac 100644 --- a/Flipcash/Core/FlipcashApp.swift +++ b/Flipcash/Core/FlipcashApp.swift @@ -32,7 +32,7 @@ struct FlipcashApp: App { } else if let benchmark = ScrollBenchmark.requested { benchmark } else { - mainScene.tunerFloating(groups: ["Chat list", "Chat list Row", "Chat list Title", "Chat list Preview", "Chat list Date", "Chat list Unread", "Chat v2 Date", "Tray Layout", "Tray Type", "Tray Destructive", "Tray Rows", "Tray Amount", "Tray Mute", "Tray E2EE", "Tray Fill", "Tray Motion", "Tray Label", "Tray Behavior", "Swipe", "Swipe Layout", "Swipe Motion", "Swipe Lens", "Swipe Failure", "Swipe Shimmer", "Amount entry"]) // LOCAL-TUNER + mainScene.tunerFloating(groups: ["Chat list", "Chat list Row", "Chat list Title", "Chat list Preview", "Chat list Date", "Chat list Unread", "Chat v2 Date", "Tray Layout", "Tray Type", "Tray Destructive", "Tray Rows", "Tray Amount", "Tray Mute", "Tray E2EE", "Tray Fill", "Tray Motion", "Tray Label", "Tray Behavior", "Swipe", "Swipe Layout", "Swipe Motion", "Swipe Lens", "Swipe Failure", "Swipe Shimmer", "Amount entry", "Swipe Dim"]) // LOCAL-TUNER } #else mainScene diff --git a/Flipcash/UI/EnterAmountView.swift b/Flipcash/UI/EnterAmountView.swift index c80c1f732..5980ee872 100644 --- a/Flipcash/UI/EnterAmountView.swift +++ b/Flipcash/UI/EnterAmountView.swift @@ -32,6 +32,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 { @@ -113,6 +116,20 @@ public struct EnterAmountView: View { isExceedingLimit ? .textError : .textSecondary } + /// The opacity everything but the swipe takes at the hosted swipe's current travel. + private var dimOpacity: Double { + guard actionOverride != nil, SwipeDim.isOn else { return 1 } + let start = SwipeDim.start + let t = min(max((Double(swipeProgress) - start) / (1 - start), 0), 1) + let curved = SwipeDim.easesIn ? t * t : t + // Opacity 0 drops a view out of hit testing; the keypad must keep taking taps. + return max(1 - (1 - SwipeDim.maxDim) * curved, 0.02) + } + + private var amountOpacity: Double { + SwipeDim.keepsAmount ? 1 : dimOpacity + } + // MARK: - Body - public var body: some View { @@ -125,6 +142,8 @@ public struct EnterAmountView: View { Spacer().frame(maxHeight: 24) header .padding(.leading, Tuner.shared.value("Amount entry/Amount left inset", default: CGFloat(20), in: 0...48, unit: .points) - 20) // LOCAL-TUNER + // The header carries the amount and its hint together, so both follow Keep amount. + .opacity(amountOpacity) } else { Spacer() @@ -143,31 +162,35 @@ public struct EnterAmountView: View { ) .foregroundStyle(.textMain) } - - switch subtitle { - case .singleTransactionLimit: - if let limit = calculator.maxTransactionAmount { - Text("Enter up to \(limit.formatted())") + .opacity(amountOpacity) + + 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(dimOpacity) } .frame(maxWidth: .infinity) } @@ -179,6 +202,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() @@ -194,7 +218,8 @@ public struct EnterAmountView: View { ) ) .padding([.leading, .trailing], -20) - + .opacity(dimOpacity) + if let actionOverride { actionOverride .disabled(!actionEnabled(enteredAmount)) @@ -215,9 +240,29 @@ public struct EnterAmountView: View { .padding(.bottom, Tuner.shared.value("Amount entry/Button bottom inset", default: CGFloat(32), in: 0...80, unit: .points) - 54) // LOCAL-TUNER } } + // 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 dims around a hosted swipe as its thumb travels. +@MainActor +private enum SwipeDim { + static var isOn: Bool { Tuner.shared.value("Swipe Dim/On", default: true) } // LOCAL-TUNER + /// The opacity everything falls to at full travel. + static var maxDim: Double { Tuner.shared.value("Swipe Dim/Max dim", default: 0.25, in: 0...1) } // LOCAL-TUNER + /// The travel where dimming begins. + static var start: Double { Tuner.shared.value("Swipe Dim/Start", default: 0, in: 0...0.9) } // LOCAL-TUNER + /// Whether the dim eases in (squared) rather than tracking travel linearly. + static var easesIn: Bool { Tuner.shared.value("Swipe Dim/Ease in", default: false) } // LOCAL-TUNER + /// Whether the entered amount stays at full opacity while the rest dims. + static var keepsAmount: Bool { Tuner.shared.value("Swipe Dim/Keep amount", default: true) } // LOCAL-TUNER +} + // MARK: - Mode - extension EnterAmountView { diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift index 10109371d..65168768e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift @@ -54,12 +54,15 @@ public struct SwipeControl: View { public var body: some View { GeometryReader { geometry in let diameter = geometry.size.height - let maxX = geometry.size.width - diameter + // 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) // LOCAL-TUNER + let maxX = geometry.size.width - thumbWidth SwipingTrack( text: text, look: lookOverride ?? SwipeMetrics.look, diameter: diameter, + thumbWidth: thumbWidth, maxX: maxX, knobX: knobX, state: state @@ -77,8 +80,10 @@ public struct SwipeControl: View { .animation(.easeOut(duration: SwipeMetrics.failureTintFade), value: state == .failure) } .overlay { - Capsule() - .strokeBorder(SwipeMetrics.strokeColor, lineWidth: 1 / displayScale) + if SwipeMetrics.showsTrackStroke { // LOCAL-TUNER + Capsule() + .strokeBorder(SwipeMetrics.strokeColor, lineWidth: 1 / displayScale) + } // LOCAL-TUNER } .modifier(SwipeShake(phase: shakePhase, distance: SwipeMetrics.shakeDistance, count: SwipeMetrics.shakeCount)) .opacity(isEnabled ? 1 : SwipeMetrics.disabledOpacity) @@ -108,7 +113,7 @@ public struct SwipeControl: View { case .failure: .bannerError.opacity(SwipeMetrics.failureTint) case .normal, .loading, .success: - .action.opacity(SwipeMetrics.fillOpacity) + SwipeMetrics.trackColor.opacity(SwipeMetrics.fillOpacity) // LOCAL-TUNER } } @@ -153,6 +158,7 @@ private struct SwipingTrack: View { let text: String let look: SwipeControlLook let diameter: CGFloat + let thumbWidth: CGFloat let maxX: CGFloat let knobX: CGFloat let state: KnobState @@ -179,7 +185,7 @@ private struct SwipingTrack: View { let labelOpacity = (SwipeMetrics.labelFades && maxX > 0) ? 1 - min(max(position / maxX, 0), 1) : 1 let wipeEdge = look == .lens ? SwipeMetrics.lensWipeEdge : SwipeMetrics.labelWipeEdge - let wipeX = SwipeMetrics.labelWipes ? position + diameter * wipeEdge : 0 + let wipeX = SwipeMetrics.labelWipes ? position + thumbWidth * wipeEdge : 0 let liftScale = isLifted && !reduceMotion ? pressScale : 1 ZStack(alignment: .leading) { @@ -193,11 +199,12 @@ private struct SwipingTrack: View { isOn: SwipeMetrics.shimmers && !reduceMotion, isRunning: isNormal && isEnabled && !isTouching )) - .padding(.horizontal, diameter) + .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 + diameter / 2, + centerX: position + nudgeX + thumbWidth / 2, radius: diameter / 2 * liftScale, + halfLength: (thumbWidth - diameter) / 2 * liftScale, bend: SwipeMetrics.lensBend, band: SwipeMetrics.lensBand, isEnabled: look == .lens && SwipeMetrics.usesGlass @@ -218,7 +225,7 @@ private struct SwipingTrack: View { } .animation(press) { $0.scaleEffect(liftScale) } .opacity(isLifted && reduceMotion ? SwipeMetrics.pressDim : 1) - .frame(width: fills ? maxX + diameter : diameter, height: diameter) + .frame(width: fills ? maxX + thumbWidth : thumbWidth, height: diameter) .contentShape(.capsule) // the lens's see-through middle takes the touch too .offset(x: fills ? 0 : position) .animation(snap, value: position) @@ -247,6 +254,14 @@ private struct SwipingTrack: View { } .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: isTouching) { _, touching in isLifted = touching } @@ -298,6 +313,33 @@ private struct SwipingTrack: View { } } +/// 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 diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift index 13fffa83c..81585f3b0 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift @@ -78,15 +78,16 @@ struct SwipeThumbSurface: View { } } - /// Clear glass masked to a soft ring, so the label reads through the middle. + /// Clear glass masked to a soft ring, so the label reads through the middle; a capsule + /// when the thumb is wider than tall. @available(iOS 26, *) private var lensRim: some View { - Circle() + Capsule() .fill(.clear) - .glassEffect(.clear.tint(.white.opacity(SwipeMetrics.lensRimTint)), in: .circle) + .glassEffect(.clear.tint(.white.opacity(SwipeMetrics.lensRimTint)), in: .capsule) .glassEffectID(glassID, in: glassNamespace) .mask { - Circle() + Capsule() .strokeBorder(.black, lineWidth: SwipeMetrics.lensRimWidth) .blur(radius: SwipeMetrics.lensRimSoftness) } @@ -97,20 +98,23 @@ struct SwipeThumbSurface: View { // MARK: - Effects -/// Bends the content under a circle at `centerX` with `radius`, through the `swipeLens` shader. +/// 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 isEnabled: Bool - nonisolated var animatableData: AnimatablePair { - get { AnimatablePair(centerX, radius) } + nonisolated var animatableData: AnimatablePair> { + get { AnimatablePair(centerX, AnimatablePair(radius, halfLength)) } set { centerX = newValue.first - radius = newValue.second + radius = newValue.second.first + halfLength = newValue.second.second } } @@ -120,6 +124,7 @@ struct SwipeLensEffect: ViewModifier, Animatable { .boundingRect, .float(centerX), .float(radius), + .float(max(halfLength, 0)), .float(bend), .float(radius * band) ), diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeLens.metal b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeLens.metal index f39c46b39..f0f0af350 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeLens.metal +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeLens.metal @@ -9,21 +9,25 @@ #include using namespace metal; -// The swipe control's lens: inside a circle, a band just within the rim samples the layer from +// 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 ) { - float2 center = float2(centerX, bounds.y + bounds.w * 0.5); + 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); diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift index 9959e1b1d..944b35903 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift @@ -37,11 +37,17 @@ enum SwipeMetrics { /// The capsule's height, which stands taller than the tray's capsule buttons. static var height: CGFloat { layout("Height", 64, 36...80) } // LOCAL-TUNER static var thumbInset: CGFloat { layout("Thumb inset", 6, 0...12) } // LOCAL-TUNER + /// The thumb's width; 0 matches its height for a round thumb, and anything wider makes it a capsule. + static var thumbWidth: CGFloat { layout("Thumb width", 0, 0...160) } // LOCAL-TUNER static var arrowSize: CGFloat { layout("Arrow size", 24, 12...32) } // LOCAL-TUNER + /// The track's wash colour, before `fillOpacity` applies. + static var trackColor: Color { Tuner.shared.value("Swipe Layout/Track color", default: Color.action, source: ".action") } // LOCAL-TUNER /// The track's `action` wash, as today's control draws it. static var fillOpacity: Double { Tuner.shared.value("Swipe Layout/Fill opacity", default: 0.1, in: 0...0.3) } // LOCAL-TUNER /// The track's hairline, the input field's resting stroke. static var strokeColor: Color { Tuner.shared.value("Swipe Layout/Stroke", default: Metrics.inputFieldStrokeColor(highlighted: false), source: "Metrics.inputFieldStrokeColor(highlighted: false)") } // LOCAL-TUNER + /// Whether the track draws its hairline. + static var showsTrackStroke: Bool { Tuner.shared.value("Swipe Layout/Track stroke", default: true) } // LOCAL-TUNER static var disabledOpacity: Double { Tuner.shared.value("Swipe Layout/Disabled opacity", default: 0.4, in: 0...1) } // LOCAL-TUNER // MARK: - Label From 732ffb041811139db1fd6b83b3a15491235f7079 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 18:46:53 +0000 Subject: [PATCH 10/42] feat(swipe): centre the label in the space beside the thumb --- .../Sources/FlipcashUI/Views/Controls/SwipeControl.swift | 4 +++- .../Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift index 65168768e..d4ec5247d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift @@ -199,7 +199,9 @@ private struct SwipingTrack: View { isOn: SwipeMetrics.shimmers && !reduceMotion, isRunning: isNormal && isEnabled && !isTouching )) - .padding(.horizontal, thumbWidth) + // Centred in the space beside the resting thumb, not on the track. + .padding(.leading, thumbWidth) + .padding(.trailing, SwipeMetrics.labelTrailingInset) .frame(maxWidth: .infinity, maxHeight: .infinity) // span the track so the mask and lens are in track coordinates .modifier(SwipeLensEffect( centerX: position + nudgeX + thumbWidth / 2, diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift index 944b35903..1190e02c6 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift @@ -37,6 +37,8 @@ enum SwipeMetrics { /// The capsule's height, which stands taller than the tray's capsule buttons. static var height: CGFloat { layout("Height", 64, 36...80) } // LOCAL-TUNER static var thumbInset: CGFloat { layout("Thumb inset", 6, 0...12) } // LOCAL-TUNER + /// Space kept clear after the label; the label centres in what's left beside the resting thumb. + static var labelTrailingInset: CGFloat { layout("Label trailing inset", 0, 0...40) } // LOCAL-TUNER /// The thumb's width; 0 matches its height for a round thumb, and anything wider makes it a capsule. static var thumbWidth: CGFloat { layout("Thumb width", 0, 0...160) } // LOCAL-TUNER static var arrowSize: CGFloat { layout("Arrow size", 24, 12...32) } // LOCAL-TUNER From 6c67915aebfe2d0d03aa689e3f05b646852cd249 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 18:50:47 +0000 Subject: [PATCH 11/42] Revert "feat(swipe): centre the label in the space beside the thumb" This reverts commit 732ffb041811139db1fd6b83b3a15491235f7079. --- .../Sources/FlipcashUI/Views/Controls/SwipeControl.swift | 4 +--- .../Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift | 2 -- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift index d4ec5247d..65168768e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift @@ -199,9 +199,7 @@ private struct SwipingTrack: View { isOn: SwipeMetrics.shimmers && !reduceMotion, isRunning: isNormal && isEnabled && !isTouching )) - // Centred in the space beside the resting thumb, not on the track. - .padding(.leading, thumbWidth) - .padding(.trailing, SwipeMetrics.labelTrailingInset) + .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, diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift index 1190e02c6..944b35903 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift @@ -37,8 +37,6 @@ enum SwipeMetrics { /// The capsule's height, which stands taller than the tray's capsule buttons. static var height: CGFloat { layout("Height", 64, 36...80) } // LOCAL-TUNER static var thumbInset: CGFloat { layout("Thumb inset", 6, 0...12) } // LOCAL-TUNER - /// Space kept clear after the label; the label centres in what's left beside the resting thumb. - static var labelTrailingInset: CGFloat { layout("Label trailing inset", 0, 0...40) } // LOCAL-TUNER /// The thumb's width; 0 matches its height for a round thumb, and anything wider makes it a capsule. static var thumbWidth: CGFloat { layout("Thumb width", 0, 0...160) } // LOCAL-TUNER static var arrowSize: CGFloat { layout("Arrow size", 24, 12...32) } // LOCAL-TUNER From e21e3315298d551e87f79a49c75f921f6a75a29b Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 18:51:51 +0000 Subject: [PATCH 12/42] feat(swipe): bake Aiden's second round of tuned values --- Flipcash/UI/EnterAmountView.swift | 4 ++-- .../FlipcashUI/Views/Controls/SwipeMetrics.swift | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Flipcash/UI/EnterAmountView.swift b/Flipcash/UI/EnterAmountView.swift index 5980ee872..236e13ac8 100644 --- a/Flipcash/UI/EnterAmountView.swift +++ b/Flipcash/UI/EnterAmountView.swift @@ -254,13 +254,13 @@ public struct EnterAmountView: View { private enum SwipeDim { static var isOn: Bool { Tuner.shared.value("Swipe Dim/On", default: true) } // LOCAL-TUNER /// The opacity everything falls to at full travel. - static var maxDim: Double { Tuner.shared.value("Swipe Dim/Max dim", default: 0.25, in: 0...1) } // LOCAL-TUNER + static var maxDim: Double { Tuner.shared.value("Swipe Dim/Max dim", default: 0.17, in: 0...1) } // LOCAL-TUNER /// The travel where dimming begins. static var start: Double { Tuner.shared.value("Swipe Dim/Start", default: 0, in: 0...0.9) } // LOCAL-TUNER /// Whether the dim eases in (squared) rather than tracking travel linearly. static var easesIn: Bool { Tuner.shared.value("Swipe Dim/Ease in", default: false) } // LOCAL-TUNER /// Whether the entered amount stays at full opacity while the rest dims. - static var keepsAmount: Bool { Tuner.shared.value("Swipe Dim/Keep amount", default: true) } // LOCAL-TUNER + static var keepsAmount: Bool { Tuner.shared.value("Swipe Dim/Keep amount", default: false) } // LOCAL-TUNER } // MARK: - Mode - diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift index 944b35903..252541ada 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift @@ -36,18 +36,18 @@ enum SwipeMetrics { /// The capsule's height, which stands taller than the tray's capsule buttons. static var height: CGFloat { layout("Height", 64, 36...80) } // LOCAL-TUNER - static var thumbInset: CGFloat { layout("Thumb inset", 6, 0...12) } // LOCAL-TUNER + static var thumbInset: CGFloat { layout("Thumb inset", 4, 0...12) } // LOCAL-TUNER /// The thumb's width; 0 matches its height for a round thumb, and anything wider makes it a capsule. - static var thumbWidth: CGFloat { layout("Thumb width", 0, 0...160) } // LOCAL-TUNER + static var thumbWidth: CGFloat { layout("Thumb width", 84, 0...160) } // LOCAL-TUNER static var arrowSize: CGFloat { layout("Arrow size", 24, 12...32) } // LOCAL-TUNER /// The track's wash colour, before `fillOpacity` applies. - static var trackColor: Color { Tuner.shared.value("Swipe Layout/Track color", default: Color.action, source: ".action") } // LOCAL-TUNER + static var trackColor: Color { Tuner.shared.value("Swipe Layout/Track color", default: Color.action.opacity(0.75), source: ".action.opacity(0.75)") } // LOCAL-TUNER /// The track's `action` wash, as today's control draws it. static var fillOpacity: Double { Tuner.shared.value("Swipe Layout/Fill opacity", default: 0.1, in: 0...0.3) } // LOCAL-TUNER /// The track's hairline, the input field's resting stroke. static var strokeColor: Color { Tuner.shared.value("Swipe Layout/Stroke", default: Metrics.inputFieldStrokeColor(highlighted: false), source: "Metrics.inputFieldStrokeColor(highlighted: false)") } // LOCAL-TUNER /// Whether the track draws its hairline. - static var showsTrackStroke: Bool { Tuner.shared.value("Swipe Layout/Track stroke", default: true) } // LOCAL-TUNER + static var showsTrackStroke: Bool { Tuner.shared.value("Swipe Layout/Track stroke", default: false) } // LOCAL-TUNER static var disabledOpacity: Double { Tuner.shared.value("Swipe Layout/Disabled opacity", default: 0.4, in: 0...1) } // LOCAL-TUNER // MARK: - Label @@ -118,7 +118,7 @@ enum SwipeMetrics { /// Whether every label takes one period to sweep, so longer labels sweep faster, as GSConfirm does. static var shimmerScalesWithLabel: Bool { Tuner.shared.value("Swipe Shimmer/Scales with label", default: true) } // LOCAL-TUNER /// The time one sweep takes when it scales with the label. - static var shimmerPeriod: TimeInterval { Tuner.shared.value("Swipe Shimmer/Period", default: 3, in: 0.5...8, unit: .seconds) } // LOCAL-TUNER + static var shimmerPeriod: TimeInterval { Tuner.shared.value("Swipe Shimmer/Period", default: 2.75, in: 0.5...8, unit: .seconds) } // LOCAL-TUNER /// The band's speed in points a second when it doesn't scale with the label. static var shimmerSpeed: Double { Tuner.shared.value("Swipe Shimmer/Speed", default: 71, in: 10...300) } // LOCAL-TUNER /// The band fading away when the sweep pauses, and back when it resumes. From 6896bd778116b5589da549b48aaf8cc9ec72b130 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 19:14:58 +0000 Subject: [PATCH 13/42] fix(swipe): lift the thumb on touch-down, even inside a sheet, on the press spring --- .../Views/Controls/SwipeControl.swift | 14 ++++--- .../Views/Controls/SwipePressDetector.swift | 42 +++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipePressDetector.swift diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift index 65168768e..baf09a0b2 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift @@ -169,13 +169,15 @@ private struct SwipingTrack: View { @GestureState private var dragOffset: CGFloat? = nil @State private var isNudging = false - // Follows the touch one update after the drag's, whose transaction disables animations. + @State private var isPressed = false + // Set on the press spring, outside the drag's animation-disabled transaction. @State private var isLifted = false @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 nudgeX: CGFloat = (isNormal && isEnabled && dragOffset == nil && isNudging && !reduceMotion) ? SwipeMetrics.nudgeDistance : 0 let fills = look == .morphFill && !isNormal @@ -232,6 +234,8 @@ private struct SwipingTrack: View { .animation(SwipeMetrics.morph.animation(reduceMotion: reduceMotion), value: fills) .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 @@ -262,11 +266,11 @@ private struct SwipingTrack: View { .allowsHitTesting(false) .accessibilityHidden(true) } - .onChange(of: isTouching) { _, touching in - isLifted = touching + .onChange(of: wantsLift) { _, lift in + withAnimation(press) { isLifted = lift } } - .sensoryFeedback(.impact(flexibility: .soft, intensity: SwipeMetrics.touchHaptic), trigger: isTouching) { wasTouching, touching in - !wasTouching && touching + .sensoryFeedback(.impact(flexibility: .soft, intensity: SwipeMetrics.touchHaptic), trigger: wantsLift) { wasLifted, lift in + !wasLifted && lift } .task { do { diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipePressDetector.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipePressDetector.swift new file mode 100644 index 000000000..a7924ecc0 --- /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(_ g: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { true } + func gestureRecognizer(_ g: UIGestureRecognizer, shouldRequireFailureOf other: UIGestureRecognizer) -> Bool { false } + func gestureRecognizer(_ g: UIGestureRecognizer, shouldBeRequiredToFailBy other: UIGestureRecognizer) -> Bool { false } + } +} From bdf6bcaaca9a1316cadaa8c0d42fe9919f16bb92 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 19:24:10 +0000 Subject: [PATCH 14/42] feat(swipe): bake Aiden's third round of tuned values --- Flipcash/UI/EnterAmountView.swift | 4 ++-- .../Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Flipcash/UI/EnterAmountView.swift b/Flipcash/UI/EnterAmountView.swift index 236e13ac8..a5d5c7342 100644 --- a/Flipcash/UI/EnterAmountView.swift +++ b/Flipcash/UI/EnterAmountView.swift @@ -254,13 +254,13 @@ public struct EnterAmountView: View { private enum SwipeDim { static var isOn: Bool { Tuner.shared.value("Swipe Dim/On", default: true) } // LOCAL-TUNER /// The opacity everything falls to at full travel. - static var maxDim: Double { Tuner.shared.value("Swipe Dim/Max dim", default: 0.17, in: 0...1) } // LOCAL-TUNER + static var maxDim: Double { Tuner.shared.value("Swipe Dim/Max dim", default: 0.1, in: 0...1) } // LOCAL-TUNER /// The travel where dimming begins. static var start: Double { Tuner.shared.value("Swipe Dim/Start", default: 0, in: 0...0.9) } // LOCAL-TUNER /// Whether the dim eases in (squared) rather than tracking travel linearly. static var easesIn: Bool { Tuner.shared.value("Swipe Dim/Ease in", default: false) } // LOCAL-TUNER /// Whether the entered amount stays at full opacity while the rest dims. - static var keepsAmount: Bool { Tuner.shared.value("Swipe Dim/Keep amount", default: false) } // LOCAL-TUNER + static var keepsAmount: Bool { Tuner.shared.value("Swipe Dim/Keep amount", default: true) } // LOCAL-TUNER } // MARK: - Mode - diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift index 252541ada..4d9746fd3 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift @@ -39,7 +39,7 @@ enum SwipeMetrics { static var thumbInset: CGFloat { layout("Thumb inset", 4, 0...12) } // LOCAL-TUNER /// The thumb's width; 0 matches its height for a round thumb, and anything wider makes it a capsule. static var thumbWidth: CGFloat { layout("Thumb width", 84, 0...160) } // LOCAL-TUNER - static var arrowSize: CGFloat { layout("Arrow size", 24, 12...32) } // LOCAL-TUNER + static var arrowSize: CGFloat { layout("Arrow size", 26, 12...32) } // LOCAL-TUNER /// The track's wash colour, before `fillOpacity` applies. static var trackColor: Color { Tuner.shared.value("Swipe Layout/Track color", default: Color.action.opacity(0.75), source: ".action.opacity(0.75)") } // LOCAL-TUNER /// The track's `action` wash, as today's control draws it. @@ -93,7 +93,7 @@ enum SwipeMetrics { // MARK: - Lens /// The lens thumb's scale under the finger, which lets it swell past the track's edge. - static var lensPressScale: CGFloat { Tuner.shared.value("Swipe Lens/Press scale", default: 1.4, in: 1...1.6, unit: .multiplier) } // LOCAL-TUNER + static var lensPressScale: CGFloat { Tuner.shared.value("Swipe Lens/Press scale", default: 1.3, in: 1...1.6, unit: .multiplier) } // LOCAL-TUNER static var lensRimWidth: CGFloat { Tuner.shared.value("Swipe Lens/Rim width", default: 0.5, in: 0...8, unit: .points) } // LOCAL-TUNER static var lensRimSoftness: CGFloat { Tuner.shared.value("Swipe Lens/Rim softness", default: 0, in: 0...6, unit: .points) } // LOCAL-TUNER static var lensRimTint: Double { Tuner.shared.value("Swipe Lens/Rim tint", default: 0, in: 0...0.5) } // LOCAL-TUNER From 54c25d43585c4df9ab29da8357efa70f9add2098 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 19:27:41 +0000 Subject: [PATCH 15/42] feat(swipe): touch haptic and lens clarity tunables --- Flipcash/Core/FlipcashApp.swift | 2 +- .../Views/Controls/SwipeControl.swift | 11 ++++- .../Views/Controls/SwipeControlLook.swift | 20 ++++++-- .../FlipcashUI/Views/Controls/SwipeLens.metal | 19 +++++++- .../Views/Controls/SwipeMetrics.swift | 48 ++++++++++++++++++- 5 files changed, 90 insertions(+), 10 deletions(-) diff --git a/Flipcash/Core/FlipcashApp.swift b/Flipcash/Core/FlipcashApp.swift index ca242f8ac..c1cebd3fe 100644 --- a/Flipcash/Core/FlipcashApp.swift +++ b/Flipcash/Core/FlipcashApp.swift @@ -32,7 +32,7 @@ struct FlipcashApp: App { } else if let benchmark = ScrollBenchmark.requested { benchmark } else { - mainScene.tunerFloating(groups: ["Chat list", "Chat list Row", "Chat list Title", "Chat list Preview", "Chat list Date", "Chat list Unread", "Chat v2 Date", "Tray Layout", "Tray Type", "Tray Destructive", "Tray Rows", "Tray Amount", "Tray Mute", "Tray E2EE", "Tray Fill", "Tray Motion", "Tray Label", "Tray Behavior", "Swipe", "Swipe Layout", "Swipe Motion", "Swipe Lens", "Swipe Failure", "Swipe Shimmer", "Amount entry", "Swipe Dim"]) // LOCAL-TUNER + mainScene.tunerFloating(groups: ["Chat list", "Chat list Row", "Chat list Title", "Chat list Preview", "Chat list Date", "Chat list Unread", "Chat v2 Date", "Tray Layout", "Tray Type", "Tray Destructive", "Tray Rows", "Tray Amount", "Tray Mute", "Tray E2EE", "Tray Fill", "Tray Motion", "Tray Label", "Tray Behavior", "Swipe", "Swipe Layout", "Swipe Motion", "Swipe Haptic", "Swipe Lens", "Swipe Failure", "Swipe Shimmer", "Amount entry", "Swipe Dim"]) // LOCAL-TUNER } #else mainScene diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift index baf09a0b2..ae377fed6 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift @@ -209,6 +209,9 @@ private struct SwipingTrack: View { halfLength: (thumbWidth - diameter) / 2 * liftScale, bend: SwipeMetrics.lensBend, band: SwipeMetrics.lensBand, + zoom: SwipeMetrics.lensZoom, + falloff: SwipeMetrics.lensFalloff, + aberration: SwipeMetrics.lensAberration, isEnabled: look == .lens && SwipeMetrics.usesGlass )) .animation(snap, value: position) @@ -269,8 +272,12 @@ private struct SwipingTrack: View { .onChange(of: wantsLift) { _, lift in withAnimation(press) { isLifted = lift } } - .sensoryFeedback(.impact(flexibility: .soft, intensity: SwipeMetrics.touchHaptic), trigger: wantsLift) { wasLifted, lift in - !wasLifted && lift + .sensoryFeedback(SwipeMetrics.hapticStyle.feedback(intensity: SwipeMetrics.hapticIntensity), trigger: wantsLift) { wasLifted, lift in + SwipeMetrics.hapticOn && !wasLifted && lift + } + // A lift that ends with the control still idle is a release without a commit. + .sensoryFeedback(SwipeMetrics.releaseHapticStyle.feedback(intensity: SwipeMetrics.releaseHapticIntensity), trigger: wantsLift) { wasLifted, lift in + SwipeMetrics.releaseHapticOn && wasLifted && !lift && state == .normal } .task { do { diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift index 81585f3b0..51b3487a3 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift @@ -84,7 +84,7 @@ struct SwipeThumbSurface: View { private var lensRim: some View { Capsule() .fill(.clear) - .glassEffect(.clear.tint(.white.opacity(SwipeMetrics.lensRimTint)), in: .capsule) + .glassEffect(lensGlass.tint(.white.opacity(SwipeMetrics.lensRimTint)), in: .capsule) .glassEffectID(glassID, in: glassNamespace) .mask { Capsule() @@ -93,6 +93,14 @@ struct SwipeThumbSurface: View { } } + @available(iOS 26, *) + private var lensGlass: Glass { + switch SwipeMetrics.lensGlass { + case .clear: .clear + case .regular: .regular + } + } + private var glassID: String { "thumb" } } @@ -107,6 +115,9 @@ struct SwipeLensEffect: ViewModifier, Animatable { var halfLength: CGFloat let bend: CGFloat let band: CGFloat + var zoom: CGFloat = 1 + var falloff: CGFloat = 2 + var aberration: CGFloat = 0 let isEnabled: Bool nonisolated var animatableData: AnimatablePair> { @@ -126,9 +137,12 @@ struct SwipeLensEffect: ViewModifier, Animatable { .float(radius), .float(max(halfLength, 0)), .float(bend), - .float(radius * band) + .float(radius * band), + .float(max(zoom, 0.01)), + .float(falloff), + .float(aberration) ), - maxSampleOffset: CGSize(width: bend, height: bend), + maxSampleOffset: CGSize(width: bend + aberration + (radius + halfLength) * (1 - 1 / max(zoom, 1)), height: bend + aberration + radius), isEnabled: isEnabled ) } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeLens.metal b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeLens.metal index f0f0af350..76c3e6250 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeLens.metal +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeLens.metal @@ -23,7 +23,10 @@ using namespace metal; float radius, float halfLength, float bend, - float band + 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. @@ -38,5 +41,17 @@ using namespace metal; float insideRim = radius - distance; float pull = 1.0 - smoothstep(0.0, max(band, 0.001), insideRim); float2 outward = fromCenter / distance; - return layer.sample(position - outward * bend * pull * pull); + // 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 index 4d9746fd3..e47fc7c86 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift @@ -74,8 +74,6 @@ enum SwipeMetrics { static var pressScale: CGFloat { Tuner.shared.value("Swipe Motion/Press scale", default: 1.06, in: 1...1.3, unit: .multiplier) } // LOCAL-TUNER /// The thumb's opacity under the finger with Reduce Motion on, standing in for the scale. static var pressDim: Double { Tuner.shared.value("Swipe Motion/Press dim", default: 0.8, in: 0.3...1) } // LOCAL-TUNER - /// The soft tap's strength at touch-down. - static var touchHaptic: Double { Tuner.shared.value("Swipe Motion/Touch haptic", default: 0.5, in: 0...1) } // LOCAL-TUNER // MARK: - Motion @@ -101,9 +99,29 @@ enum SwipeMetrics { static var lensBend: CGFloat { Tuner.shared.value("Swipe Lens/Bend", default: 6.1, in: 0...30, unit: .points) } // LOCAL-TUNER /// How deep inside the rim the bend reaches, as a share of the lens's radius. static var lensBand: CGFloat { Tuner.shared.value("Swipe Lens/Band", default: 0.73, in: 0.05...1) } // LOCAL-TUNER + /// The glass the lens rim is made of. + static var lensGlass: SwipeLensGlass { Tuner.shared.value("Swipe Lens/Glass", default: SwipeLensGlass.clear) } // LOCAL-TUNER + /// How much the label is magnified inside the lens; 1 leaves it at size. + static var lensZoom: CGFloat { Tuner.shared.value("Swipe Lens/Zoom", default: 1, in: 1...1.6, unit: .multiplier) } // LOCAL-TUNER + /// How sharply the bend falls off from the rim; higher keeps the distortion tighter to the edge. + static var lensFalloff: CGFloat { Tuner.shared.value("Swipe Lens/Falloff", default: 2, in: 0.5...8) } // LOCAL-TUNER + /// How far apart red and blue split where the lens bends, in points; 0 is none. + static var lensAberration: CGFloat { Tuner.shared.value("Swipe Lens/Aberration", default: 0, in: 0...4, unit: .points) } // LOCAL-TUNER /// The wipe's edge for the lens look, which shows the label through the thumb. static var lensWipeEdge: CGFloat { Tuner.shared.value("Swipe Lens/Wipe edge", default: 0.42, in: 0...1) } // LOCAL-TUNER + // MARK: - Haptic + + /// Whether the thumb taps at touch-down. + static var hapticOn: Bool { Tuner.shared.value("Swipe Haptic/On", default: true) } // LOCAL-TUNER + static var hapticStyle: SwipeHapticStyle { Tuner.shared.value("Swipe Haptic/Style", default: SwipeHapticStyle.impactSoft) } // LOCAL-TUNER + /// The touch-down tap's strength, for the styles that take one. + static var hapticIntensity: Double { Tuner.shared.value("Swipe Haptic/Intensity", default: 0.5, in: 0...1) } // LOCAL-TUNER + /// Whether a second tap plays when the finger lifts without committing. + static var releaseHapticOn: Bool { Tuner.shared.value("Swipe Haptic/On release", default: false) } // LOCAL-TUNER + static var releaseHapticStyle: SwipeHapticStyle { Tuner.shared.value("Swipe Haptic/Release style", default: SwipeHapticStyle.impactSoft) } // LOCAL-TUNER + static var releaseHapticIntensity: Double { Tuner.shared.value("Swipe Haptic/Release intensity", default: 0.5, in: 0...1) } // LOCAL-TUNER + // MARK: - Shimmer /// Whether a bright band sweeps the label while the control waits for a swipe. @@ -154,3 +172,29 @@ extension ChatSpring { reduceMotion ? .spring(duration: duration, bounce: 0) : animation } } + +/// The haptic styles the swipe control can tap with. +enum SwipeHapticStyle: String, CaseIterable, TunableEnum { // LOCAL-TUNER + case impactSoft, impactSolid, impactRigid, impactLight, impactMedium, impactHeavy, selection, alignment, levelChange, start + + /// This style as feedback at `intensity`, which `selection`, `alignment`, `levelChange` and `start` ignore. + func feedback(intensity: Double) -> SensoryFeedback { + switch self { + case .impactSoft: .impact(flexibility: .soft, intensity: intensity) + case .impactSolid: .impact(flexibility: .solid, intensity: intensity) + case .impactRigid: .impact(flexibility: .rigid, intensity: intensity) + case .impactLight: .impact(weight: .light, intensity: intensity) + case .impactMedium: .impact(weight: .medium, intensity: intensity) + case .impactHeavy: .impact(weight: .heavy, intensity: intensity) + case .selection: .selection + case .alignment: .alignment + case .levelChange: .levelChange + case .start: .start + } + } +} + +/// The glass the lens rim is drawn in. +enum SwipeLensGlass: String, CaseIterable, TunableEnum { // LOCAL-TUNER + case clear, regular +} From b2f750a53b136b1de1bd61a816344209bf492792 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 19:46:09 +0000 Subject: [PATCH 16/42] feat(swipe): bake the lens look for real use Final values as constants, tuner removed from the control; glass thumb and fill looks removed; release haptic only on a non-committing release. --- Flipcash/Core/FlipcashApp.swift | 2 +- .../Screens/Settings/SwipeFlowShowcase.swift | 32 +-- Flipcash/UI/EnterAmountView.swift | 49 ++--- .../Views/Controls/SwipeControl.swift | 77 +++----- .../Views/Controls/SwipeControlLook.swift | 73 ++----- .../Views/Controls/SwipeMetrics.swift | 182 +++++------------- .../Views/Controls/SwipeShowcase.swift | 48 +---- 7 files changed, 124 insertions(+), 339 deletions(-) diff --git a/Flipcash/Core/FlipcashApp.swift b/Flipcash/Core/FlipcashApp.swift index c1cebd3fe..1f728868b 100644 --- a/Flipcash/Core/FlipcashApp.swift +++ b/Flipcash/Core/FlipcashApp.swift @@ -32,7 +32,7 @@ struct FlipcashApp: App { } else if let benchmark = ScrollBenchmark.requested { benchmark } else { - mainScene.tunerFloating(groups: ["Chat list", "Chat list Row", "Chat list Title", "Chat list Preview", "Chat list Date", "Chat list Unread", "Chat v2 Date", "Tray Layout", "Tray Type", "Tray Destructive", "Tray Rows", "Tray Amount", "Tray Mute", "Tray E2EE", "Tray Fill", "Tray Motion", "Tray Label", "Tray Behavior", "Swipe", "Swipe Layout", "Swipe Motion", "Swipe Haptic", "Swipe Lens", "Swipe Failure", "Swipe Shimmer", "Amount entry", "Swipe Dim"]) // LOCAL-TUNER + mainScene.tunerFloating(groups: ["Chat list", "Chat list Row", "Chat list Title", "Chat list Preview", "Chat list Date", "Chat list Unread", "Chat v2 Date", "Tray Layout", "Tray Type", "Tray Destructive", "Tray Rows", "Tray Amount", "Tray Mute", "Tray E2EE", "Tray Fill", "Tray Motion", "Tray Label", "Tray Behavior", "Amount entry"]) // LOCAL-TUNER } #else mainScene diff --git a/Flipcash/Core/Screens/Settings/SwipeFlowShowcase.swift b/Flipcash/Core/Screens/Settings/SwipeFlowShowcase.swift index c449a750d..4de480799 100644 --- a/Flipcash/Core/Screens/Settings/SwipeFlowShowcase.swift +++ b/Flipcash/Core/Screens/Settings/SwipeFlowShowcase.swift @@ -5,16 +5,16 @@ // Copyright © 2026 Code Inc. All rights reserved. // -#if DEBUG +#if DEBUG // LOCAL-TUNER: the swipe showcase, a local feel tool import SwiftUI import FlipcashUI import FlipcashCore -/// The swipe showcase's root: the looks side by side, and, when signed in, the swipe inside the +/// 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 looks show. +/// reads, so without them (the sandbox) only the control shows. struct SwipeShowcaseHost: View { @Environment(Session.self) private var session: Session? @@ -80,7 +80,6 @@ struct SwipeFlowShowcase: View { @State private var enteredAmount = "" @State private var outcome: SwipeShowcaseOutcome = .succeeds - @State private var look: SwipeControlLook = .lens // LOCAL-TUNER /// Enabled once there's an amount, standing in for the send screen's `canSend`. private func isActionEnabled(_ entered: String) -> Bool { @@ -108,7 +107,6 @@ struct SwipeFlowShowcase: View { )) ) { SwipeControl(text: "Swipe to Send", action: outcome.action) - .swipeControlLook(look) // LOCAL-TUNER } .foregroundStyle(.textMain) .padding(20) @@ -119,28 +117,12 @@ struct SwipeFlowShowcase: View { // MARK: - Controls - private var controls: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 12) { - Picker("Outcome", selection: $outcome) { - ForEach(SwipeShowcaseOutcome.allCases) { outcome in - Text(outcome.title).tag(outcome) - } - } - .pickerStyle(.segmented) - - Picker("Look", selection: $look) { // LOCAL-TUNER - ForEach(SwipeControlLook.allCases, id: \.self) { look in // LOCAL-TUNER - Text(look.showcaseLetter).tag(look) // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER - .pickerStyle(.segmented) // LOCAL-TUNER - .frame(width: 120) // LOCAL-TUNER + Picker("Outcome", selection: $outcome) { + ForEach(SwipeShowcaseOutcome.allCases) { outcome in + Text(outcome.title).tag(outcome) } - - Text("\(look.showcaseCaption) · Hold three fingers to open the tuner → Swipe") // LOCAL-TUNER - .font(.appTextCaption) // LOCAL-TUNER - .foregroundStyle(Color.textSecondary) // LOCAL-TUNER } + .pickerStyle(.segmented) } } #endif diff --git a/Flipcash/UI/EnterAmountView.swift b/Flipcash/UI/EnterAmountView.swift index a5d5c7342..30abe995a 100644 --- a/Flipcash/UI/EnterAmountView.swift +++ b/Flipcash/UI/EnterAmountView.swift @@ -118,16 +118,10 @@ public struct EnterAmountView: View { /// The opacity everything but the swipe takes at the hosted swipe's current travel. private var dimOpacity: Double { - guard actionOverride != nil, SwipeDim.isOn else { return 1 } - let start = SwipeDim.start - let t = min(max((Double(swipeProgress) - start) / (1 - start), 0), 1) - let curved = SwipeDim.easesIn ? t * t : t + guard actionOverride != nil else { return 1 } + let travel = min(max(Double(swipeProgress), 0), 1) // Opacity 0 drops a view out of hit testing; the keypad must keep taking taps. - return max(1 - (1 - SwipeDim.maxDim) * curved, 0.02) - } - - private var amountOpacity: Double { - SwipeDim.keepsAmount ? 1 : dimOpacity + return max(1 - (1 - SwipeDim.maxDim) * travel, 0.02) } // MARK: - Body - @@ -142,8 +136,6 @@ public struct EnterAmountView: View { Spacer().frame(maxHeight: 24) header .padding(.leading, Tuner.shared.value("Amount entry/Amount left inset", default: CGFloat(20), in: 0...48, unit: .points) - 20) // LOCAL-TUNER - // The header carries the amount and its hint together, so both follow Keep amount. - .opacity(amountOpacity) } else { Spacer() @@ -162,7 +154,6 @@ public struct EnterAmountView: View { ) .foregroundStyle(.textMain) } - .opacity(amountOpacity) Group { switch subtitle { @@ -224,9 +215,8 @@ public struct EnterAmountView: View { actionOverride .disabled(!actionEnabled(enteredAmount)) // Sits in the Next button's slot: same side and bottom insets as the button below. - .padding(.top, Tuner.shared.value("Amount entry/Keypad to swipe", default: CGFloat(20), in: 0...48, unit: .points)) // LOCAL-TUNER - .padding(.horizontal, Tuner.shared.value("Amount entry/Button side inset", default: CGFloat(32), in: 16...56, unit: .points) - 20) // LOCAL-TUNER - .padding(.bottom, Tuner.shared.value("Amount entry/Button bottom inset", default: CGFloat(32), in: 0...80, unit: .points) - 54) // LOCAL-TUNER + .padding(.top, SwipeDim.keypadToSwipe) + .modifier(ActionSlotInsets()) } else { Button(action: action) { ButtonStateLabel(actionTitle ?? mode.actionName, state: actionState) @@ -236,8 +226,7 @@ public struct EnterAmountView: View { // Amount entry v2: 32 from the screen's sides and bottom (the screens already pad 20), // 22 below the keypad. .padding(.top, Tuner.shared.value("Amount entry/Keypad to button", default: CGFloat(20), in: 0...48, unit: .points)) // LOCAL-TUNER - .padding(.horizontal, Tuner.shared.value("Amount entry/Button side inset", default: CGFloat(32), in: 16...56, unit: .points) - 20) // LOCAL-TUNER - .padding(.bottom, Tuner.shared.value("Amount entry/Button bottom inset", default: CGFloat(32), in: 0...80, unit: .points) - 54) // LOCAL-TUNER + .modifier(ActionSlotInsets()) } } // The control animates the value on its own snap-back and sends every frame, so it lands unanimated. @@ -249,18 +238,22 @@ public struct EnterAmountView: View { // MARK: - Swipe dim - -/// How the amount screen dims around a hosted swipe as its thumb travels. -@MainActor +/// How the amount screen lays out and dims around a hosted swipe; the entered amount never dims. private enum SwipeDim { - static var isOn: Bool { Tuner.shared.value("Swipe Dim/On", default: true) } // LOCAL-TUNER - /// The opacity everything falls to at full travel. - static var maxDim: Double { Tuner.shared.value("Swipe Dim/Max dim", default: 0.1, in: 0...1) } // LOCAL-TUNER - /// The travel where dimming begins. - static var start: Double { Tuner.shared.value("Swipe Dim/Start", default: 0, in: 0...0.9) } // LOCAL-TUNER - /// Whether the dim eases in (squared) rather than tracking travel linearly. - static var easesIn: Bool { Tuner.shared.value("Swipe Dim/Ease in", default: false) } // LOCAL-TUNER - /// Whether the entered amount stays at full opacity while the rest dims. - static var keepsAmount: Bool { Tuner.shared.value("Swipe Dim/Keep amount", default: true) } // LOCAL-TUNER + /// 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 { + content + .padding(.horizontal, Tuner.shared.value("Amount entry/Button side inset", default: CGFloat(32), in: 16...56, unit: .points) - 20) // LOCAL-TUNER + .padding(.bottom, Tuner.shared.value("Amount entry/Button bottom inset", default: CGFloat(32), in: 0...80, unit: .points) - 54) // LOCAL-TUNER + } } // MARK: - Mode - diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift index ae377fed6..eb2c9dbff 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift @@ -32,10 +32,8 @@ public struct SwipeControlFailure: Error { /// ``` public struct SwipeControl: View { - @Environment(\.displayScale) private var displayScale @Environment(\.isEnabled) private var isEnabled @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.swipeControlLook) private var lookOverride @State private var knobX: CGFloat = 0 @State private var state: KnobState = .normal @@ -55,12 +53,11 @@ public struct SwipeControl: View { GeometryReader { geometry in 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) // LOCAL-TUNER + let thumbWidth = min(max(SwipeMetrics.thumbWidth, diameter), geometry.size.width) let maxX = geometry.size.width - thumbWidth SwipingTrack( text: text, - look: lookOverride ?? SwipeMetrics.look, diameter: diameter, thumbWidth: thumbWidth, maxX: maxX, @@ -79,12 +76,6 @@ public struct SwipeControl: View { .fill(trackFill) .animation(.easeOut(duration: SwipeMetrics.failureTintFade), value: state == .failure) } - .overlay { - if SwipeMetrics.showsTrackStroke { // LOCAL-TUNER - Capsule() - .strokeBorder(SwipeMetrics.strokeColor, lineWidth: 1 / displayScale) - } // LOCAL-TUNER - } .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 } @@ -113,7 +104,7 @@ public struct SwipeControl: View { case .failure: .bannerError.opacity(SwipeMetrics.failureTint) case .normal, .loading, .success: - SwipeMetrics.trackColor.opacity(SwipeMetrics.fillOpacity) // LOCAL-TUNER + SwipeMetrics.trackFill } } @@ -150,13 +141,11 @@ public struct SwipeControl: View { /// Owns the high-frequency drag and idle-nudge state, and renders the label and /// thumb together so a drag (or nudge) re-renders only the track, not the -/// control's background, border, or failure tint. As the thumb advances it -/// masks the label away from the left and fades the still-visible remainder -/// toward transparent. +/// 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 look: SwipeControlLook let diameter: CGFloat let thumbWidth: CGFloat let maxX: CGFloat @@ -172,6 +161,9 @@ private struct SwipingTrack: View { @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 { @@ -180,15 +172,12 @@ private struct SwipingTrack: View { let wantsLift = (isPressed || isTouching) && isNormal && isEnabled let position = knobX + (dragOffset ?? 0) let nudgeX: CGFloat = (isNormal && isEnabled && dragOffset == nil && isNudging && !reduceMotion) ? SwipeMetrics.nudgeDistance : 0 - let fills = look == .morphFill && !isNormal let snap = SwipeMetrics.snapBack.animation(reduceMotion: reduceMotion) let nudge = SwipeMetrics.nudge.animation(reduceMotion: reduceMotion) let press = SwipeMetrics.press.animation(reduceMotion: reduceMotion) - let labelOpacity = (SwipeMetrics.labelFades && maxX > 0) ? 1 - min(max(position / maxX, 0), 1) : 1 - let wipeEdge = look == .lens ? SwipeMetrics.lensWipeEdge : SwipeMetrics.labelWipeEdge - let wipeX = SwipeMetrics.labelWipes ? position + thumbWidth * wipeEdge : 0 - let liftScale = isLifted && !reduceMotion ? pressScale : 1 + let labelOpacity = maxX > 0 ? 1 - min(max(position / maxX, 0), 1) : 1 + let liftScale = isLifted && !reduceMotion ? SwipeMetrics.pressScale : 1 ZStack(alignment: .leading) { Text(text) @@ -198,7 +187,7 @@ private struct SwipingTrack: View { .foregroundStyle(.textMain) // Before the lens, so the band bends with the label. .modifier(SwipeShimmer( - isOn: SwipeMetrics.shimmers && !reduceMotion, + isOn: !reduceMotion, isRunning: isNormal && isEnabled && !isTouching )) .padding(.horizontal, thumbWidth) @@ -212,29 +201,23 @@ private struct SwipingTrack: View { zoom: SwipeMetrics.lensZoom, falloff: SwipeMetrics.lensFalloff, aberration: SwipeMetrics.lensAberration, - isEnabled: look == .lens && SwipeMetrics.usesGlass + isEnabled: SwipeMetrics.usesGlass )) .animation(snap, value: position) .animation(nudge, value: nudgeX) .animation(press, value: isLifted) - .mask(alignment: .leading) { - Color.black - .opacity(labelOpacity) - .offset(x: wipeX) - .animation(snap, value: wipeX) - } + .opacity(labelOpacity) .opacity(isNormal ? 1 : 0) - SwipeThumbSurface(look: look, isIdle: isNormal, glassNamespace: glassNamespace) { + SwipeThumbSurface(isIdle: isNormal, glassNamespace: glassNamespace) { thumbIcon } .animation(press) { $0.scaleEffect(liftScale) } .opacity(isLifted && reduceMotion ? SwipeMetrics.pressDim : 1) - .frame(width: fills ? maxX + thumbWidth : thumbWidth, height: diameter) + .frame(width: thumbWidth, height: diameter) .contentShape(.capsule) // the lens's see-through middle takes the touch too - .offset(x: fills ? 0 : position) + .offset(x: position) .animation(snap, value: position) - .animation(SwipeMetrics.morph.animation(reduceMotion: reduceMotion), value: fills) .offset(x: nudgeX) .animation(nudge, value: nudgeX) // Sees touch-down even where a sheet holds the drag back until the finger moves. @@ -249,12 +232,15 @@ private struct SwipingTrack: View { 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 { return } + guard SwipeControl.shouldCommit(percent: percent, velocity: velocity) else { + releaseCount += 1 + return + } onCommit() } ) .disabled(!isNormal) - // Reduce Motion swaps the thumb by identity at commit and reset, so the morph and + // 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) @@ -272,13 +258,10 @@ private struct SwipingTrack: View { .onChange(of: wantsLift) { _, lift in withAnimation(press) { isLifted = lift } } - .sensoryFeedback(SwipeMetrics.hapticStyle.feedback(intensity: SwipeMetrics.hapticIntensity), trigger: wantsLift) { wasLifted, lift in - SwipeMetrics.hapticOn && !wasLifted && lift - } - // A lift that ends with the control still idle is a release without a commit. - .sensoryFeedback(SwipeMetrics.releaseHapticStyle.feedback(intensity: SwipeMetrics.releaseHapticIntensity), trigger: wantsLift) { wasLifted, lift in - SwipeMetrics.releaseHapticOn && wasLifted && !lift && state == .normal + .sensoryFeedback(SwipeMetrics.pressHaptic, trigger: wantsLift) { wasLifted, lift in + !wasLifted && lift } + .sensoryFeedback(SwipeMetrics.releaseHaptic, trigger: releaseCount) .task { do { try await Task.delay(seconds: 3) @@ -294,13 +277,6 @@ private struct SwipingTrack: View { } } - private var pressScale: CGFloat { - switch look { - case .glassThumb, .morphFill: SwipeMetrics.pressScale - case .lens: SwipeMetrics.lensPressScale - } - } - // Every swap between the arrow and the progress snaps. private var thumbIcon: some View { ZStack { @@ -367,11 +343,8 @@ private enum KnobState { #Preview { Background(color: .backgroundMain) { VStack(spacing: 32) { - ForEach(SwipeControlLook.allCases, id: \.self) { look in - SwipeControl(text: "Swipe to Send") { - try await Task.delay(seconds: 2) - } - .swipeControlLook(look) + SwipeControl(text: "Swipe to Send") { + try await Task.delay(seconds: 2) } SwipeControl(text: "Swipe to Fail") { diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift index 51b3487a3..637044f5e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift @@ -7,38 +7,12 @@ import SwiftUI -/// The ways ``SwipeControl`` can look; they share the track, the drag and every behaviour, and differ -/// in the thumb and in how a commit plays. -public enum SwipeControlLook: String, CaseIterable { - /// A glass thumb that parks at the end on commit and shows the progress in place. - case glassThumb - /// The glass thumb, which grows to fill the track on commit and shows the progress centred. - case morphFill - /// A see-through glass rim that bends the label as it passes; commits as `glassThumb` does. - case lens -} - -extension SwipeControlLook: TunableEnum {} // LOCAL-TUNER: conformance - -extension EnvironmentValues { - /// The look ``SwipeControl`` takes, or `nil` for the tuner's pick. - @Entry var swipeControlLook: SwipeControlLook? = nil -} - -public extension View { - /// Sets the look of every ``SwipeControl`` in this view; `nil` leaves the default. - func swipeControlLook(_ look: SwipeControlLook?) -> some View { - environment(\.swipeControlLook, look) - } -} - -// MARK: - Thumb surfaces +// MARK: - Thumb surface -/// The thumb's surface with `content` on it: the composer's glass (a tinted material before -/// iOS 26). The lens look draws a see-through glass rim while idle, with `content` above it. +/// The thumb's surface with `content` on it. While idle on iOS 26 it is a see-through clear-glass +/// lens with `content` above it; otherwise the composer's glass (a tinted material before iOS 26). struct SwipeThumbSurface: View { - let look: SwipeControlLook let isIdle: Bool let glassNamespace: Namespace.ID @ViewBuilder let content: Content @@ -49,14 +23,14 @@ struct SwipeThumbSurface: View { } // Outside the container, which composites its glass above sibling content. .overlay { - if showsRim { + if showsLens { face } } } - private var showsRim: Bool { - look == .lens && isIdle && SwipeMetrics.usesGlass + private var showsLens: Bool { + isIdle && SwipeMetrics.usesGlass } private var face: some View { @@ -65,42 +39,29 @@ struct SwipeThumbSurface: View { @ViewBuilder private var surface: some View { - if SwipeMetrics.forcesFallback { // LOCAL-TUNER: draws `composerGlass`'s iOS 18 branch on iOS 26 - face - .background(Color.white.opacity(0.03), in: .capsule) // LOCAL-TUNER - .background(.ultraThinMaterial, in: .capsule) // LOCAL-TUNER - } else if showsRim { + if showsLens { if #available(iOS 26, *) { lensRim } } else { - face.composerGlass(in: Capsule(), id: glassID, namespace: glassNamespace, isInteractive: SwipeMetrics.glassInteractive) + face.composerGlass(in: Capsule(), id: glassID, namespace: glassNamespace, isInteractive: false) } } - /// Clear glass masked to a soft ring, so the label reads through the middle; a capsule - /// when the thumb is wider than tall. + /// Clear glass masked to a ring, so the label reads through the middle; it shares an ID with + /// the composer glass so the thumb morphs between them at commit and reset. @available(iOS 26, *) private var lensRim: some View { Capsule() .fill(.clear) - .glassEffect(lensGlass.tint(.white.opacity(SwipeMetrics.lensRimTint)), in: .capsule) + .glassEffect(.clear, in: .capsule) .glassEffectID(glassID, in: glassNamespace) .mask { Capsule() .strokeBorder(.black, lineWidth: SwipeMetrics.lensRimWidth) - .blur(radius: SwipeMetrics.lensRimSoftness) } } - @available(iOS 26, *) - private var lensGlass: Glass { - switch SwipeMetrics.lensGlass { - case .clear: .clear - case .regular: .regular - } - } - private var glassID: String { "thumb" } } @@ -115,9 +76,9 @@ struct SwipeLensEffect: ViewModifier, Animatable { var halfLength: CGFloat let bend: CGFloat let band: CGFloat - var zoom: CGFloat = 1 - var falloff: CGFloat = 2 - var aberration: CGFloat = 0 + let zoom: CGFloat + let falloff: CGFloat + let aberration: CGFloat let isEnabled: Bool nonisolated var animatableData: AnimatablePair> { @@ -162,7 +123,7 @@ struct SwipeShimmer: ViewModifier { .overlay { content .mask(alignment: .leading) { band } - .opacity(isRunning ? SwipeMetrics.shimmerBandOpacity : 0) + .opacity(isRunning ? 1 : 0) .animation(.easeOut(duration: SwipeMetrics.shimmerFade), value: isRunning) } } else { @@ -174,12 +135,12 @@ struct SwipeShimmer: ViewModifier { GeometryReader { proxy in let width = SwipeMetrics.shimmerBandWidth let travel = proxy.size.width + width * 2 - let period = SwipeMetrics.shimmerScalesWithLabel ? SwipeMetrics.shimmerPeriod : travel / SwipeMetrics.shimmerSpeed + 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 = period > 0 ? elapsed.truncatingRemainder(dividingBy: period) / period : 0 + let progress = elapsed.truncatingRemainder(dividingBy: period) / period Rectangle() .frame(width: width) diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift index e47fc7c86..a623648d0 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift @@ -7,26 +7,19 @@ import SwiftUI -/// The swipe control's look and feel values: the track, the thumb, the lens, the failure beat and -/// every spring they move on. +/// 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. /// -/// Every value is live in the tuner during the feel round and bakes back to a constant once a look -/// is picked. The commit thresholds are not here: they are behaviour, and stay in `SwipeControl`. +/// The commit thresholds are not here: they are behaviour, and stay in `SwipeControl`. @MainActor enum SwipeMetrics { - // MARK: - Look + // MARK: - Glass - /// The look every ``SwipeControl`` takes unless a view sets one with `swipeControlLook(_:)`. - static var look: SwipeControlLook { Tuner.shared.value("Swipe/Look", default: SwipeControlLook.lens) } // LOCAL-TUNER - - /// Draws the iOS 18 material thumb on iOS 26 too, so the fallback can be judged on a new phone. - static var forcesFallback: Bool { Tuner.shared.value("Swipe/iOS 18 fallback", default: false) } // LOCAL-TUNER - - /// Whether the thumb is Liquid Glass: iOS 26, and the fallback isn't being forced. + /// 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, *) { - !forcesFallback + true } else { false } @@ -35,135 +28,84 @@ enum SwipeMetrics { // MARK: - Track /// The capsule's height, which stands taller than the tray's capsule buttons. - static var height: CGFloat { layout("Height", 64, 36...80) } // LOCAL-TUNER - static var thumbInset: CGFloat { layout("Thumb inset", 4, 0...12) } // LOCAL-TUNER - /// The thumb's width; 0 matches its height for a round thumb, and anything wider makes it a capsule. - static var thumbWidth: CGFloat { layout("Thumb width", 84, 0...160) } // LOCAL-TUNER - static var arrowSize: CGFloat { layout("Arrow size", 26, 12...32) } // LOCAL-TUNER - /// The track's wash colour, before `fillOpacity` applies. - static var trackColor: Color { Tuner.shared.value("Swipe Layout/Track color", default: Color.action.opacity(0.75), source: ".action.opacity(0.75)") } // LOCAL-TUNER - /// The track's `action` wash, as today's control draws it. - static var fillOpacity: Double { Tuner.shared.value("Swipe Layout/Fill opacity", default: 0.1, in: 0...0.3) } // LOCAL-TUNER - /// The track's hairline, the input field's resting stroke. - static var strokeColor: Color { Tuner.shared.value("Swipe Layout/Stroke", default: Metrics.inputFieldStrokeColor(highlighted: false), source: "Metrics.inputFieldStrokeColor(highlighted: false)") } // LOCAL-TUNER - /// Whether the track draws its hairline. - static var showsTrackStroke: Bool { Tuner.shared.value("Swipe Layout/Track stroke", default: false) } // LOCAL-TUNER - static var disabledOpacity: Double { Tuner.shared.value("Swipe Layout/Disabled opacity", default: 0.4, in: 0...1) } // LOCAL-TUNER + static let height: CGFloat = 64 + 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 + static let arrowSize: CGFloat = 26 + /// The track's faint `action` wash. + static let trackFill: Color = .action.opacity(0.75).opacity(0.1) + static let disabledOpacity: Double = 0.4 // MARK: - Label /// How far the label may shrink to fit one line at large Dynamic Type sizes. - static var labelMinimumScale: CGFloat { Tuner.shared.value("Swipe Layout/Label min scale", default: 0.7, in: 0.5...1) } // LOCAL-TUNER - /// Whether the label is wiped away from the left as the thumb passes. - static var labelWipes: Bool { Tuner.shared.value("Swipe Layout/Label wipes", default: false) } // LOCAL-TUNER - /// Where across the thumb the wipe's edge sits, from its leading edge (0) to its trailing edge (1). - static var labelWipeEdge: CGFloat { Tuner.shared.value("Swipe Layout/Wipe edge", default: 0.29, in: 0...1) } // LOCAL-TUNER - /// Whether what's left of the label fades as the thumb travels. - static var labelFades: Bool { Tuner.shared.value("Swipe Layout/Label fades", default: true) } // LOCAL-TUNER + static let labelMinimumScale: CGFloat = 0.7 // MARK: - Thumb - /// Whether the thumb's glass also swells under the finger, on top of the press scale. - static var glassInteractive: Bool { Tuner.shared.value("Swipe Layout/Glass interactive", default: false) } // LOCAL-TUNER /// How far the idle nudge pushes the thumb right. - static var nudgeDistance: CGFloat { layout("Nudge distance", 16, 0...40) } // LOCAL-TUNER - - // MARK: - Press - - /// The thumb's scale under the finger, for the glass thumb and the morph. - static var pressScale: CGFloat { Tuner.shared.value("Swipe Motion/Press scale", default: 1.06, in: 1...1.3, unit: .multiplier) } // LOCAL-TUNER + 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 var pressDim: Double { Tuner.shared.value("Swipe Motion/Press dim", default: 0.8, in: 0.3...1) } // LOCAL-TUNER + 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 var snapBack: ChatSpring { spring("Swipe Motion/Snap back", 0.4, 0) } // LOCAL-TUNER - /// The thumb growing to fill the track on commit, in the morph look. - static var morph: ChatSpring { spring("Swipe Motion/Morph", 0.4, 0) } // LOCAL-TUNER + static let snapBack = ChatSpring(duration: 0.4, bounce: 0) /// The thumb lifting under the finger and settling on release. - static var press: ChatSpring { spring("Swipe Motion/Press", 0.3, 0.25) } // LOCAL-TUNER + static let press = ChatSpring(duration: 0.3, bounce: 0.25) /// The idle nudge's push and return. - static var nudge: ChatSpring { spring("Swipe Motion/Nudge", 0.25, 0) } // LOCAL-TUNER - /// With Reduce Motion on, the crossfade that replaces the morph and the thumb's return after a send. - static var reduceMotionFade: TimeInterval { Tuner.shared.value("Swipe Motion/Reduce Motion fade", default: 0.2, in: 0...0.5, unit: .seconds) } // LOCAL-TUNER + 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 - /// The lens thumb's scale under the finger, which lets it swell past the track's edge. - static var lensPressScale: CGFloat { Tuner.shared.value("Swipe Lens/Press scale", default: 1.3, in: 1...1.6, unit: .multiplier) } // LOCAL-TUNER - static var lensRimWidth: CGFloat { Tuner.shared.value("Swipe Lens/Rim width", default: 0.5, in: 0...8, unit: .points) } // LOCAL-TUNER - static var lensRimSoftness: CGFloat { Tuner.shared.value("Swipe Lens/Rim softness", default: 0, in: 0...6, unit: .points) } // LOCAL-TUNER - static var lensRimTint: Double { Tuner.shared.value("Swipe Lens/Rim tint", default: 0, in: 0...0.5) } // LOCAL-TUNER + /// The width of the clear-glass ring the idle lens is masked to. + static let lensRimWidth: CGFloat = 0 /// How far the lens pulls the label at the rim. - static var lensBend: CGFloat { Tuner.shared.value("Swipe Lens/Bend", default: 6.1, in: 0...30, unit: .points) } // LOCAL-TUNER + static let lensBend: CGFloat = 10 /// How deep inside the rim the bend reaches, as a share of the lens's radius. - static var lensBand: CGFloat { Tuner.shared.value("Swipe Lens/Band", default: 0.73, in: 0.05...1) } // LOCAL-TUNER - /// The glass the lens rim is made of. - static var lensGlass: SwipeLensGlass { Tuner.shared.value("Swipe Lens/Glass", default: SwipeLensGlass.clear) } // LOCAL-TUNER + static let lensBand: CGFloat = 0.34 /// How much the label is magnified inside the lens; 1 leaves it at size. - static var lensZoom: CGFloat { Tuner.shared.value("Swipe Lens/Zoom", default: 1, in: 1...1.6, unit: .multiplier) } // LOCAL-TUNER + static let lensZoom: CGFloat = 1.19 /// How sharply the bend falls off from the rim; higher keeps the distortion tighter to the edge. - static var lensFalloff: CGFloat { Tuner.shared.value("Swipe Lens/Falloff", default: 2, in: 0.5...8) } // LOCAL-TUNER - /// How far apart red and blue split where the lens bends, in points; 0 is none. - static var lensAberration: CGFloat { Tuner.shared.value("Swipe Lens/Aberration", default: 0, in: 0...4, unit: .points) } // LOCAL-TUNER - /// The wipe's edge for the lens look, which shows the label through the thumb. - static var lensWipeEdge: CGFloat { Tuner.shared.value("Swipe Lens/Wipe edge", default: 0.42, in: 0...1) } // LOCAL-TUNER + 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 - /// Whether the thumb taps at touch-down. - static var hapticOn: Bool { Tuner.shared.value("Swipe Haptic/On", default: true) } // LOCAL-TUNER - static var hapticStyle: SwipeHapticStyle { Tuner.shared.value("Swipe Haptic/Style", default: SwipeHapticStyle.impactSoft) } // LOCAL-TUNER - /// The touch-down tap's strength, for the styles that take one. - static var hapticIntensity: Double { Tuner.shared.value("Swipe Haptic/Intensity", default: 0.5, in: 0...1) } // LOCAL-TUNER - /// Whether a second tap plays when the finger lifts without committing. - static var releaseHapticOn: Bool { Tuner.shared.value("Swipe Haptic/On release", default: false) } // LOCAL-TUNER - static var releaseHapticStyle: SwipeHapticStyle { Tuner.shared.value("Swipe Haptic/Release style", default: SwipeHapticStyle.impactSoft) } // LOCAL-TUNER - static var releaseHapticIntensity: Double { Tuner.shared.value("Swipe Haptic/Release intensity", default: 0.5, in: 0...1) } // LOCAL-TUNER + /// 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 - /// Whether a bright band sweeps the label while the control waits for a swipe. - static var shimmers: Bool { Tuner.shared.value("Swipe Shimmer/On", default: true) } // LOCAL-TUNER - static var shimmerBandWidth: CGFloat { Tuner.shared.value("Swipe Shimmer/Band width", default: 30, in: 4...120, unit: .points) } // LOCAL-TUNER - static var shimmerAngle: Double { Tuner.shared.value("Swipe Shimmer/Angle", default: 15, in: -60...60, unit: .degrees) } // LOCAL-TUNER - static var shimmerBlur: CGFloat { Tuner.shared.value("Swipe Shimmer/Blur", default: 5, in: 0...20, unit: .points) } // LOCAL-TUNER - /// The band's opacity: how bright the label reads where the band passes. - static var shimmerBandOpacity: Double { Tuner.shared.value("Swipe Shimmer/Band opacity", default: 1, in: 0...1) } // LOCAL-TUNER + static let shimmerBandWidth: CGFloat = 30 + static let shimmerAngle: Double = 15 + static let shimmerBlur: CGFloat = 5 /// The label's opacity outside the band, which leaves the band room to read brighter. - static var shimmerLabelOpacity: Double { Tuner.shared.value("Swipe Shimmer/Label opacity", default: 0.6, in: 0...1) } // LOCAL-TUNER - /// Whether every label takes one period to sweep, so longer labels sweep faster, as GSConfirm does. - static var shimmerScalesWithLabel: Bool { Tuner.shared.value("Swipe Shimmer/Scales with label", default: true) } // LOCAL-TUNER - /// The time one sweep takes when it scales with the label. - static var shimmerPeriod: TimeInterval { Tuner.shared.value("Swipe Shimmer/Period", default: 2.75, in: 0.5...8, unit: .seconds) } // LOCAL-TUNER - /// The band's speed in points a second when it doesn't scale with the label. - static var shimmerSpeed: Double { Tuner.shared.value("Swipe Shimmer/Speed", default: 71, in: 10...300) } // LOCAL-TUNER + 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 var shimmerFade: TimeInterval { Tuner.shared.value("Swipe Shimmer/Fade", default: 0.2, in: 0...0.6, unit: .seconds) } // LOCAL-TUNER + static let shimmerFade: TimeInterval = 0.2 // MARK: - Failure - static var shakeDistance: CGFloat { Tuner.shared.value("Swipe Failure/Shake distance", default: 8, in: 0...24, unit: .points) } // LOCAL-TUNER - static var shakeCount: Int { Tuner.shared.value("Swipe Failure/Shake count", default: 3, in: 1...8) } // LOCAL-TUNER - static var shakeDuration: TimeInterval { Tuner.shared.value("Swipe Failure/Shake duration", default: 0.4, in: 0.1...1, unit: .seconds) } // LOCAL-TUNER + static let shakeDistance: CGFloat = 8 + static let shakeCount = 3 + static let shakeDuration: TimeInterval = 0.4 /// The `bannerError` wash the track takes while it shows the failure. - static var failureTint: Double { Tuner.shared.value("Swipe Failure/Tint opacity", default: 0.35, in: 0...1) } // LOCAL-TUNER - static var failureTintFade: TimeInterval { Tuner.shared.value("Swipe Failure/Tint fade", default: 0.15, in: 0...0.5, unit: .seconds) } // LOCAL-TUNER + static let failureTint: Double = 0.35 + static let failureTintFade: TimeInterval = 0.15 /// How long the failure shows before the control resets. - static var failureHold: TimeInterval { Tuner.shared.value("Swipe Failure/Hold", default: 0.6, in: 0...2, unit: .seconds) } // LOCAL-TUNER - - // MARK: - Tuner - - private static func layout(_ name: String, _ value: CGFloat, _ range: ClosedRange) -> CGFloat { - Tuner.shared.value("Swipe Layout/\(name)", default: value, in: range, unit: .points) // LOCAL-TUNER - } - - // Bakes back to `ChatSpring(duration:bounce:)` lets. - private static func spring(_ key: String, _ duration: TimeInterval, _ bounce: Double) -> ChatSpring { - let spring = Tuner.value(key, default: Spring(duration: duration, bounce: bounce), form: "ChatSpring(duration: {duration}, bounce: {bounce})") // LOCAL-TUNER - return ChatSpring(duration: spring.duration, bounce: spring.bounce) - } + static let failureHold: TimeInterval = 0.6 } extension ChatSpring { @@ -172,29 +114,3 @@ extension ChatSpring { reduceMotion ? .spring(duration: duration, bounce: 0) : animation } } - -/// The haptic styles the swipe control can tap with. -enum SwipeHapticStyle: String, CaseIterable, TunableEnum { // LOCAL-TUNER - case impactSoft, impactSolid, impactRigid, impactLight, impactMedium, impactHeavy, selection, alignment, levelChange, start - - /// This style as feedback at `intensity`, which `selection`, `alignment`, `levelChange` and `start` ignore. - func feedback(intensity: Double) -> SensoryFeedback { - switch self { - case .impactSoft: .impact(flexibility: .soft, intensity: intensity) - case .impactSolid: .impact(flexibility: .solid, intensity: intensity) - case .impactRigid: .impact(flexibility: .rigid, intensity: intensity) - case .impactLight: .impact(weight: .light, intensity: intensity) - case .impactMedium: .impact(weight: .medium, intensity: intensity) - case .impactHeavy: .impact(weight: .heavy, intensity: intensity) - case .selection: .selection - case .alignment: .alignment - case .levelChange: .levelChange - case .start: .start - } - } -} - -/// The glass the lens rim is drawn in. -enum SwipeLensGlass: String, CaseIterable, TunableEnum { // LOCAL-TUNER - case clear, regular -} diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift index 3b76d2f68..3363bce13 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift @@ -20,8 +20,8 @@ public enum SwipeSandbox { } } -/// Every look of the swipe control side by side at the send screen's width, each driven by a -/// local closure that succeeds, fails or is disabled, so the looks can be felt without sending. +/// 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 @@ -35,7 +35,8 @@ public struct SwipeShowcaseScreen: View { VStack(spacing: 0) { controls Spacer(minLength: 24) - looks + SwipeControl(text: label, action: outcome.action) + .disabled(outcome == .disabled) } .padding(20) } @@ -56,28 +57,6 @@ public struct SwipeShowcaseScreen: View { Toggle("Long label", isOn: $isLongLabel) .font(.appTextMedium) .foregroundStyle(Color.textMain) - - Text("Hold three fingers to open the tuner → Swipe") // LOCAL-TUNER - .font(.appTextCaption) // LOCAL-TUNER - .foregroundStyle(Color.textSecondary) // LOCAL-TUNER - } - } - - // MARK: - Looks - - - private var looks: some View { - VStack(alignment: .leading, spacing: 28) { - ForEach(SwipeControlLook.allCases, id: \.self) { look in // LOCAL-TUNER - VStack(alignment: .leading, spacing: 10) { - Text(look.showcaseCaption) // LOCAL-TUNER - .font(.appTextCaption) - .foregroundStyle(Color.textSecondary) - - SwipeControl(text: label, action: outcome.action) - .swipeControlLook(look) // LOCAL-TUNER - .disabled(outcome == .disabled) - } - } } } @@ -121,25 +100,6 @@ public enum SwipeShowcaseOutcome: String, CaseIterable, Identifiable { } } -// MARK: - Look captions - - -extension SwipeControlLook { // LOCAL-TUNER - - /// The look's letter and name, e.g. "C · Lens". // LOCAL-TUNER - public var showcaseCaption: String { // LOCAL-TUNER - switch self { // LOCAL-TUNER - case .glassThumb: "A · Glass thumb" // LOCAL-TUNER - case .morphFill: "B · Thumb becomes the button" // LOCAL-TUNER - case .lens: "C · Lens" // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER - - /// The look's letter alone, for a compact picker. // LOCAL-TUNER - public var showcaseLetter: String { // LOCAL-TUNER - String(showcaseCaption.prefix(1)) // LOCAL-TUNER - } // LOCAL-TUNER -} // LOCAL-TUNER - #Preview("Swipe showcase") { SwipeShowcaseScreen() } From c228fb4ae75f00b51c98684d2aa2b84372bd7c40 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 20:01:12 +0000 Subject: [PATCH 17/42] fix(swipe): design review fold-in; lock the keypad while the swipe is off rest, keep errors bright --- Flipcash/UI/EnterAmountView.swift | 15 ++++++++++++--- .../FlipcashUI/Views/Controls/SwipeControl.swift | 2 ++ .../Views/Controls/SwipeControlLook.swift | 16 ++++++---------- .../FlipcashUI/Views/Controls/SwipeMetrics.swift | 14 +++++++++++--- .../Views/Controls/SwipePressDetector.swift | 6 +++--- 5 files changed, 34 insertions(+), 19 deletions(-) diff --git a/Flipcash/UI/EnterAmountView.swift b/Flipcash/UI/EnterAmountView.swift index 30abe995a..6add4a009 100644 --- a/Flipcash/UI/EnterAmountView.swift +++ b/Flipcash/UI/EnterAmountView.swift @@ -120,8 +120,15 @@ public struct EnterAmountView: View { private var dimOpacity: Double { guard actionOverride != nil else { return 1 } let travel = min(max(Double(swipeProgress), 0), 1) - // Opacity 0 drops a view out of hit testing; the keypad must keep taking taps. - return max(1 - (1 - SwipeDim.maxDim) * travel, 0.02) + 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 - @@ -181,7 +188,7 @@ public struct EnterAmountView: View { EmptyView() } } - .opacity(dimOpacity) + .opacity(subtitleOpacity) } .frame(maxWidth: .infinity) } @@ -210,6 +217,8 @@ public struct EnterAmountView: View { ) .padding([.leading, .trailing], -20) .opacity(dimOpacity) + // Locked whenever the thumb is off rest: dragging, sending and the success hold. + .allowsHitTesting(swipeProgress == 0) if let actionOverride { actionOverride diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift index eb2c9dbff..b35895c78 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControl.swift @@ -43,6 +43,8 @@ public struct SwipeControl: View { 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 diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift index 637044f5e..73fa6fef1 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeControlLook.swift @@ -9,8 +9,8 @@ import SwiftUI // MARK: - Thumb surface -/// The thumb's surface with `content` on it. While idle on iOS 26 it is a see-through clear-glass -/// lens with `content` above it; otherwise the composer's glass (a tinted material before iOS 26). +/// 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 @@ -41,25 +41,21 @@ struct SwipeThumbSurface: View { private var surface: some View { if showsLens { if #available(iOS 26, *) { - lensRim + lensGlass } } else { face.composerGlass(in: Capsule(), id: glassID, namespace: glassNamespace, isInteractive: false) } } - /// Clear glass masked to a ring, so the label reads through the middle; it shares an ID with - /// the composer glass so the thumb morphs between them at commit and reset. + /// 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 lensRim: some View { + private var lensGlass: some View { Capsule() .fill(.clear) .glassEffect(.clear, in: .capsule) .glassEffectID(glassID, in: glassNamespace) - .mask { - Capsule() - .strokeBorder(.black, lineWidth: SwipeMetrics.lensRimWidth) - } } private var glassID: String { "thumb" } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift index a623648d0..01d2c7729 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeMetrics.swift @@ -29,12 +29,15 @@ enum SwipeMetrics { /// 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.75).opacity(0.1) + static let trackFill: Color = .action.opacity(0.075) + /// The whole control's opacity while disabled. static let disabledOpacity: Double = 0.4 // MARK: - Label @@ -64,8 +67,6 @@ enum SwipeMetrics { // MARK: - Lens - /// The width of the clear-glass ring the idle lens is masked to. - static let lensRimWidth: CGFloat = 0 /// 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. @@ -86,8 +87,11 @@ enum SwipeMetrics { // 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 @@ -98,11 +102,15 @@ enum SwipeMetrics { // 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 diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipePressDetector.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipePressDetector.swift index a7924ecc0..39ead1fc5 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipePressDetector.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipePressDetector.swift @@ -35,8 +35,8 @@ struct SwipePressDetector: UIGestureRecognizerRepresentable { func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { Coordinator() } final class Coordinator: NSObject, UIGestureRecognizerDelegate { - func gestureRecognizer(_ g: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { true } - func gestureRecognizer(_ g: UIGestureRecognizer, shouldRequireFailureOf other: UIGestureRecognizer) -> Bool { false } - func gestureRecognizer(_ g: UIGestureRecognizer, shouldBeRequiredToFailBy other: UIGestureRecognizer) -> Bool { false } + 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 } } } From f21b300aa6993988678e618181cefb5f8ec4d172 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 20:16:13 +0000 Subject: [PATCH 18/42] docs(profile): survey, data model and round-1 directions for the profile pages --- .claude/plans/2026-10-29-profile-pages.md | 161 ++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 .claude/plans/2026-10-29-profile-pages.md diff --git a/.claude/plans/2026-10-29-profile-pages.md b/.claude/plans/2026-10-29-profile-pages.md new file mode 100644 index 000000000..b9bd80839 --- /dev/null +++ b/.claude/plans/2026-10-29-profile-pages.md @@ -0,0 +1,161 @@ +# Profile pages redesign (User, Private Group, Public Group) + +Branch `feat/ui-profile` (local only), worktree `~/Developer/code-ios-app-profile`, cut from +`feat/ui-swipe-to-send` @ c228fb4a. Round 1 is **Figma only**, on the page **Profiles** (814:2) in +"Components – Flipcash" (`R2TbSk4RbeLZz29rJr61uE`). Aiden makes every taste call. + +Note for shipping: this plan lives under `.claude/`, which a design PR must not touch. Leave it out +of any PR, or ask the dev first. + +## 1. What exists today + +Two screens and one shared header: + +| Profile | Screen | Shared pieces | +|---|---|---| +| User | `Screens/Profile/UserProfileScreen.swift` (`UserProfileContent` :46, `UserProfileViewModel` :462) | `ProfileHeaderView`, `ProfileCoverBanner`, `ProfilePinnedBackdrop` | +| Private group + public group | `Screens/Profile/ChatProfileScreen.swift`, one screen that branches on `isPrivate` (:70) and `isMember` (:59) | same, plus `GroupChattingGrid` | + +The shared skeleton: +- A cover 214pt tall, made of literals. With no image it shows a flat grey `coverPlaceholder`, and that grey is what most groups show. +- An 84pt avatar overlapping the cover by 42pt, with a 5pt ring. +- A 38pt share circle. +- A title in `.appDisplaySmall`. +- The side inset is 24 (`ProfileHeaderMetrics.inset`). The house standard says 20. +- The CTA is `CodeButton(.filled)`: a square-ish white slab, not the 54pt capsule. + +The bottom slot: +- On group pages, "Leave Chat" (`.subtle`) often takes the CTA position. +- No profile has a `#Preview`, a loading skeleton or an error state. + +**The CTA slot carries every state.** Its logic is load-bearing and must not change: + +| Profile and state | CTA | Source | +|---|---|---| +| User, new | "Send £X to Start Chatting" → gate → `StartChattingSheet` (Lens swipe) | `ProfilePinnedAction`, `UserProfileScreen.swift:351` | +| User, fee unknown | "Start Chatting" → send amount | same | +| User, existing DM | "Open Chat" | same | +| User, blocked | "Unblock" | same | +| Public group | "Open Chat"; the join happens in the chat's gate, not on the profile | `ChatProfileScreen.swift:512` | +| Private, requester | "Request to Join", then "Requested Xm ago" (disabled) and "Cancel Request" | `:550-585` | +| Private, invite preview (Brandon, in development) | "Join Group" | Figma 12846:59 | +| Member, any group | "Leave Chat" (subtle) | `:682` | + +## 2. What the data really has + +Sources: flipcash2-client-protocol 0.20.0 (f0bc47ee), `FlipcashCore/Models/Profile.swift` and +`Conversation.swift`. + +### User (`profile.v1.UserProfile` → `Profile`) + +| Field | Status | +|---|---| +| Display name, username, avatar, cover, bio (≤160), joined date (`join_ts`) | exists and is shown | +| Minimum to chat | exists. It is shown via `TipFloor.toOpenDM(...).displayed`, not the raw field. | +| Favourite public groups | exists (`GetFeaturedGroups`) | +| Card colour (`tipCardCustomization.colorHex`) | exists, fetched, **not displayed** | +| X profile (`social_profiles`: handle, verified type, follower count) | **in the proto, not mapped in Swift**. Dev question. | +| Currencies they hold | **absent**. Holdings exist only on groups. Backend question. | +| Chat state with this user | derived locally (DM exists, blocked, muted) | + +### Groups (`chat.v1.Metadata` → `Conversation`) + +| Field | Private | Public | +|---|---|---| +| Title, picture, cover, description (≤160, not persisted, so nil after a cold start) | exists | exists | +| Member count (`roster_summary.member_count`) | shown | **exists, not shown** | +| Faces | roster (`GetRoster`, members only, paged by 50) | `SampleChatters` (≤100, creator first, then recent senders; not the full roster) | +| Host | `creator` (wave badge) | same | +| Roles beyond host | absent | absent | +| Join requirement | host approval through the lobby; no `rules` | `rules.listener` minimum balance (`amount`, `mints[≤1]`) | +| Minimum to chat | absent | `rules.speaker` (falls back to the join rule) | +| Holdings (`holdings_summary`: mint, quarks, holder_count, as_of) | absent | **exists, never displayed**. Only set above a server holder minimum. | +| Last activity, last message | exists, not shown | exists, not shown | +| Created date, category or tags, "full" | absent | absent | +| Muted | `viewer_state.mute` | same | + +## 3. Load-bearing walls (do not touch) + +- **Every CTA's action and its resolve order.** That covers `ProfilePinnedAction`, `tapPinned`, the give-cash gate, the `StartChattingSheet` hand-off and `PostPaymentChat`. +- **Fee and amount text.** It is rendered by `TipFloor` and `FiatAmount.formatted*`; we show it, we don't re-derive it. +- **Group logic:** the gate (`ConversationGate`), membership (`isMember` comes from the roster), `canEdit` as the sole gate on Edit, and the lobby request and cancel flow. +- **Navigation:** the `ChatProfileOrigin` and `UserProfileOrigin` semantics, `popToRoot` on leave, block popping, and the profile being a **pushed page** (not a tray). +- **Accessibility ids:** `profile-*`, `group-profile-*` and `chat-profile-*` stay. + +## 4. In-flight work the redesign must leave room for + +- **Group Invites** (Brandon, 12844:56): + - an invite-preview state ("You've been invited to join", Join Group, a 10-face grid with "+39 more"); + - a dead-link subtitle; + - an "Invite to …" sheet from Share; + - "Invite Links" in the ⋯ menu. +- **Moderation** (12419:77643): "Moderation ›" in the host's ⋯ menu, and an "In {group}" section (Silence, Remove) on a member's profile menu. +- **Join Requests on chats**: lives on the chat-list row, and the profile's Join Requests card stays. + +## 5. Prior art (short) + +The prior-art agent's full report has Mobbin links. Patterns worth keeping: +1. One skeleton with a swapped subtitle (Telegram). +2. The price in a single pinned capsule (Orb, Telegram, Revolut). +3. Metadata as one muted line (Threads, Luma, Partiful). +4. A colour wash taken from the cover or avatar instead of glass over content (Luma, Discord, Arc). +5. A facepile plus a count for groups (Instagram, Base). +6. Private vs public described by how you get in, not by a lock (Telegram, Discord). +7. Own vs other differs only in the action row (Cash App, Partiful). + +Avoid three-stat columns, upsell cards and glass on content. + +## 6. Directions (round 1: Figma, all three types side by side) + +All three share: +- The CTA is a 54pt capsule, `.filled`, the only filled button on the screen. +- Glass only on the back and ⋯ circles and the CTA bar on iOS 26+, with a material fallback on iOS 18. +- 20pt margins (`screen/margin`). +- 2pt Central icons and rows v2. +- Presses are a small scale; state changes snap. + +### A · Editorial +- **Type-led and calm.** The cover becomes a soft wash: the cover image, or a tint taken from the avatar, fading to black about a third of the way down. No grey block. +- **Identity block:** a large left-aligned name with the avatar on the right (about 72pt), then one muted line ("@ted · Joined Feb 2026", "Private · 5 members · Host approves", "Public · 22 people · $100 to join"), then the bio or description. +- **Sections** are quiet rows v2 lists: Favourite groups, Members (facepile row → list), Requirements. + +References: Threads, Luma, Cash App. + +### B · Cover stage +- **An evolution of today's skeleton, made photographic.** A taller full-bleed cover that fades into black, with no hard edge. +- **Empty covers** get a gradient taken from the avatar instead of grey. +- **Identity is centred:** about a 96pt avatar overlapping the fade, then the name and handle. +- **One grouped info card per question:** + - User: "Minimum to chat · Joined". + - Group: "Access" (Private · host approves, or Public · Join $100 · Chat $100), then "Members" as a facepile and count. + +References: Orb, Venmo org, Revolut group, Discord. + +### C · The card +- **Identity lives on an object.** A rounded card at the top holds the cover, avatar and name, like the Flipcash tip card or a Family wallet card. The card colour can come from `tipCardCustomization.colorHex`, which is fetched but unused today. +- **Group cards** carry the facepile and access line on the card itself. +- **Below the card**, grouped rows v2 (style B) hold the rest. +- **The most ownable direction:** it ties the profile to "Share Profile → View Card". + +References: Family, Base token card, the Flipcash tip card. + +## 7. Taste calls for Aiden (A/B) + +1. Minimum to chat appears twice, on the stat card and in the CTA label. **A:** CTA only. **B:** keep both. +2. Leave Chat. **A:** at the end of the scroll as a quiet destructive row. **B:** in the ⋯ menu. B moves a control between view code and `ProfileMenuItems`, so ask the dev first. +3. Empty cover. **A:** a tint taken from the avatar. **B:** no cover area at all when there's no image. +4. Avatar. **A:** left-aligned. **B:** centred. + +## 8. Open questions for the dev or backend + +1. Currencies a user holds: not in `UserProfile`. Is a field planned, and is it public? +2. The X `social_profiles` in the proto: OK to map into `Profile` and show a verified handle? +3. Public groups: OK to show `member_count` (it exists) and `holdings_summary` ("Held by N people")? What are the semantics of the server holder minimum? +4. Show met or unmet requirement state on the public profile? That needs the `ConversationGate` comparison, so the dev owns it. +5. Should the public profile's CTA ever say "Join" rather than "Open Chat"? Today the join is in the chat by design (Oct 3 note: "The profile always opens the chat"). +6. Group created date: absent. Is it wanted? +7. Loading and error: there's no skeleton, and a failed fetch is silent. Is a design-only skeleton OK? +8. Own profile: your own handle routes to the tip card, not this screen. The Oct 3 design had "the You tab becomes your profile". Out of scope here? + +## 9. Log +- 2026-10-28: worktree set up; four surveys and the prior-art report done; Profiles page created; directions proposed. From 84d0b66665d2f8e3b94178a13b6f69a441f5430a Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 20:43:38 +0000 Subject: [PATCH 19/42] docs(profile): round-1 frames, critique fold-in and open taste calls --- .claude/plans/2026-10-29-profile-pages.md | 63 ++++++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/.claude/plans/2026-10-29-profile-pages.md b/.claude/plans/2026-10-29-profile-pages.md index b9bd80839..1d9102183 100644 --- a/.claude/plans/2026-10-29-profile-pages.md +++ b/.claude/plans/2026-10-29-profile-pages.md @@ -109,7 +109,8 @@ Avoid three-stat columns, upsell cards and glass on content. All three share: - The CTA is a 54pt capsule, `.filled`, the only filled button on the screen. -- Glass only on the back and ⋯ circles and the CTA bar on iOS 26+, with a material fallback on iOS 18. +- Glass only on the back and ⋯ circles, on iOS 26+, with a material fallback on iOS 18. The CTA floats over + the shipped fade: no bar surface and no glass. - 20pt margins (`screen/margin`). - 2pt Central icons and rows v2. - Presses are a small scale; state changes snap. @@ -157,5 +158,63 @@ References: Family, Base token card, the Flipcash tip card. 7. Loading and error: there's no skeleton, and a failed fetch is silent. Is a design-only skeleton OK? 8. Own profile: your own handle routes to the tip card, not this screen. The Oct 3 design had "the You tab becomes your profile". Out of scope here? -## 9. Log +## 9. Round 1 frames (Figma) + +Page Profiles `814:2`. Sections and frames (User / Private / Public, iOS, Dark): + +| Direction | Section | Frames | +|---|---|---| +| A · Editorial | 818:2 | 822:2 · 822:162 · 822:360 | +| B · Cover stage | 818:5 | 822:3997 · 825:4344 · 828:935 | +| C · The card | 818:8 | 823:460 · 824:572 · 827:811 | + +The states drawn: +- User: a new contact, with "Send £1.51 to Start Chatting". +- Private: a member who arrived from a link, with Open Chat. +- Public: a non-member, with Open Chat. + +Photos are gradient stand-ins named `⚠︎ photo:`. New pieces are named `⚠︎ not in Screen Kit:` (header wash, cover fade, +identity block, facepile, stat card, profile card). + +**The critique (design-critic, fresh context)** returned "approve with changes". It was folded in as follows: +- **Leave Chat restored on C · Private.** Also removed there: the redundant "See all 5" row and the slab behind the CTA. +- **CTA fade.** B gets the shipped fade, and B's cover is shortened so content clears the CTA. +- **B · Public logo:** the "f" glyph read as Facebook's logo, so it was replaced. +- **Copy:** "Invite only" became "The host approves who joins"; "Favourite groups" became the shipped "Favorite Public + Groups". +- **Public "22 members":** the chevron is gone, because non-members have no roster. +- **Hit areas:** Share is 44pt, and C's share circle has no glass. + +Kept against the critique: +- **"Your Balance" on public groups:** it ships today (`ChatProfileScreen.swift:453`). +- **Pure black page:** Aiden chose #000000 on 2026-10-28. The critic read `backgroundMain` #19191A in design-standard + §1, so that doc is out of date. + +**Not drawn yet (round 2, for the chosen direction):** +- **Group opened from its chat:** no pinned CTA, and Leave Chat ends the scroll. This is the most common route. +- **User CTA states:** Start Chatting (fee unknown), Open Chat (DM), Unblock, and own profile (no CTA). +- **Private CTA states:** Request to Join; "Requested Xm ago" plus Cancel Request (two controls in one slot); + Join Group (invite preview, Brandon); and the host with the Join Requests card. +- **Empty and fallback states:** loading, empty bio, no favourites, long title, and AX5. + +## 10. Taste calls after round 1 + +1. Avatar: right of the name (A), centred over the cover (B), or top-left on the card (C). +2. Identity alignment: left-aligned (A, C) or centred (B). +3. Cover: a wash (A), a full-bleed photo (B), or held inside the card (C). Empty cover: a tint from the avatar, or + nothing. +4. Minimum to chat: CTA only (A), or a stat plus the CTA (B, C). +5. Leave Chat: at the end of the scroll (A, C), or in the ⋯ menu (B, needs the dev's OK). Red or `.subtle`. +6. Private members: a full list (A, C), or a facepile (B). +7. Name size: 34 (A), 28 (B) or 24 (C). +8. CTA inset: 32 (the Amount and tray decision of 2026-10-19), or 20 (the content edge, which the critic prefers). +9. Card radius: 12 (standard rule 15), or about 22 (`card/radius`, rows v2 style B and trays). + +The standard is silent on: +- whether a profile counts as a money screen (layout rule 2: money is the largest text); +- section-heading casing; +- a header avatar size token. + +## 11. Log - 2026-10-28: worktree set up; four surveys and the prior-art report done; Profiles page created; directions proposed. +- 2026-10-28: round 1 built (A, B, C × 3 types); critique run; fix pass folded in. From 46a6d4c4cff511c87e612f8fc627e1e5875cb160 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 21:07:09 +0000 Subject: [PATCH 20/42] docs(profile): round-2 states and X social link, frame ids and data notes --- .claude/plans/2026-10-29-profile-pages.md | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/.claude/plans/2026-10-29-profile-pages.md b/.claude/plans/2026-10-29-profile-pages.md index 1d9102183..e85748d8e 100644 --- a/.claude/plans/2026-10-29-profile-pages.md +++ b/.claude/plans/2026-10-29-profile-pages.md @@ -197,6 +197,48 @@ Kept against the critique: Join Group (invite preview, Brandon); and the host with the Join Requests card. - **Empty and fallback states:** loading, empty bio, no favourites, long title, and AX5. +## 9b. Round 2: states and social links (2026-10-28) + +The sections are now 2490×3340 each: A at y 0, B at y 3500, C at y 7000. Row 1 is unchanged except for the X link. Row 2 +holds the User states (Own profile / Already chatting / Blocked). Row 3 holds the Group states (Private requester / +Private requested / Private host / Public opened from its chat / Private invite preview). + +| Direction | User states | Group states | +|---|---|---| +| A | 840:1015 · 840:1153 · 840:1278 | 840:1410 · 840:1573 · 840:1749 · 840:1942 · 840:2091 | +| B | 842:1788 · 842:1951 · 842:2096 | 842:2248 · 842:2376 · 842:2517 · 842:2688 · 842:2864 | +| C | 846:2571 · 846:2735 · 846:2891 | 846:3054 · 846:3252 · 846:3463 · 846:3673 · 846:3856 | + +**Social links.** The proto has X only: `UserProfile.social_profiles`, at most one item, an `XProfile` with username, name, +`verified_type` and `follower_count`. The `LinkSocialAccount` and `UnlinkSocialAccount` RPCs exist. Swift `Profile` +doesn't map any of it yet, and Instagram and the other networks are absent. + +How the frames use it: +- X is drawn with sample data (@tedlivingston, verified, 12.4K followers). +- A shows it as an inline link, B as a centred chip, and C as a row in the details card. +- The own profile shows "Connect X". +- Questions for the dev and backend: map `social_profiles`; add the link flow; other networks need backend fields. + +**CTA by state** (labels as shipped): + +| State | CTA | +|---|---| +| Own profile | none (Edit Profile and Share sit at the header) | +| Already chatting | Open Chat | +| Blocked | Unblock, plus a Blocked chip | +| Private requester | Request to Join, with the roster hidden | +| Private requested | the approval line, a disabled "Requested 2h ago" and a subtle Cancel Request | +| Private host | Edit Group plus a Join Requests card ("3 people are waiting"), then Open Chat | +| Public opened from its chat | no pinned CTA, and Leave Chat ends the scroll | +| Private invite preview | Join Group (Brandon's in-flight design), 48 members, "+38 more" | + +Deviations noted by the builder: +- On the host frames in A and C, Leave Chat sits below the fold. +- In B, opened from its chat, Leave Chat ends the scroll (as today) instead of moving to the ⋯ menu as B's round 1 had it. +- To keep content clear of the fade, B's body moved up and C's card was trimmed to 196. + +No critique was run on round 2 (Aiden asked for one agent). Run `/critique` on the chosen direction before code. + ## 10. Taste calls after round 1 1. Avatar: right of the name (A), centred over the cover (B), or top-left on the card (C). From b4b101c3f13b760c21cbb62d2fa73680d940ce35 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 22:00:52 +0000 Subject: [PATCH 21/42] docs(profile): direction D, poster users, cover-only public groups --- .claude/plans/2026-10-29-profile-pages.md | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/.claude/plans/2026-10-29-profile-pages.md b/.claude/plans/2026-10-29-profile-pages.md index e85748d8e..7ad674479 100644 --- a/.claude/plans/2026-10-29-profile-pages.md +++ b/.claude/plans/2026-10-29-profile-pages.md @@ -239,6 +239,38 @@ Deviations noted by the builder: No critique was run on round 2 (Aiden asked for one agent). Run `/critique` on the chosen direction before code. +## 9c. Direction D · Mix (Aiden's steer, 2026-10-28) + +Aiden wants the layout to vary by type: +- **Users** get a full-screen poster made from the profile photo, melting into grouped fields, with no cover. +- **Public groups** keep a cover. +- **Private groups** drop the cover. + +All three share left-aligned identity and C's grouped cards. + +Section `852:3443` (x 0, y 10500, 1526×2340): + +| Row | Frames | +|---|---| +| 1 | User · Poster 852:3447 · Private · Member 852:3603 · Public · Non-member 852:3774 | +| 2 | User · No photo 855:3817 · User · Own profile 855:4001 · User · Scrolled 855:4202 | + +How it's built: +- **Melt:** an alpha-only `bg/primary` mask over the lower 45% of the poster (y 308–560), 11 smootherstep stops, plus a + black legibility scrim peaking at 55%. No band. +- **No photo:** the card colour (`tipCardCustomization.colorHex`) with a 120pt monogram. +- **Scrolled:** the poster leaves with a +60 parallax, and the inline nav title takes over. + +Questions for the dev: +1. Is the uploaded `profile_picture` resolution enough for full-screen? +2. Should the card colour be the fallback when there's no photo? +3. Today's content only scrolls about 180pt, so the collapsed title would never show. That needs content or an inset decision. +4. Private groups still have `cover_picture` in the data; it's just not shown here. + +Rest states: +- The User poster's Favorite Groups card sits under the CTA fade. It's meant to scroll. +- On the private page, Leave Chat brushes the fade. + ## 10. Taste calls after round 1 1. Avatar: right of the name (A), centred over the cover (B), or top-left on the card (C). From fb49f6946b726fd56ac2693877e1456b52fa4db4 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 22:17:01 +0000 Subject: [PATCH 22/42] docs(profile): D revision, member grid, requirement pass, social chips --- .claude/plans/2026-10-29-profile-pages.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.claude/plans/2026-10-29-profile-pages.md b/.claude/plans/2026-10-29-profile-pages.md index 7ad674479..d675504b2 100644 --- a/.claude/plans/2026-10-29-profile-pages.md +++ b/.claude/plans/2026-10-29-profile-pages.md @@ -271,6 +271,23 @@ Rest states: - The User poster's Favorite Groups card sits under the CTA fade. It's meant to scroll. - On the private page, Leave Chat brushes the fade. +### D revision 1 (Aiden's feedback, 2026-10-28) + +- **Private:** members are a 3-column grid of 72pt avatars with names inside a `list/card` card, the host first with a + Hand5Finger badge, under the header "Members · 5". +- **Public requirements:** a "requirement pass" tile with the amount as the hero (`type/amount`), then "of ◉ Jeffy" and + "to join and chat". + - When join == chat it collapses to one tile. The dev needs to confirm that comparison: same currency, same amount. + - The new frame 863:4171 covers Join ≠ Chat, with two tiles ($50 to join, $100 to chat). + - "Your balance" stays as the footer. + - "Jeffy" is a sample currency name. +- **User:** + - "Joined" moves out of the Details card into a `text/tertiary` footnote at the end of the scroll. + - Socials are a horizontally scrolling chip row in the identity block (X with verified mark, Instagram, TikTok; + Central brand icons). Own profile adds a dashed "+ Add" chip. + - The Details card holds only "Minimum to chat". + - Instagram and TikTok are drawn as if linked. They need backend fields and link flows (the proto has X only). + ## 10. Taste calls after round 1 1. Avatar: right of the name (A), centred over the cover (B), or top-left on the card (C). From 4379ff643bca9cd1f727bce571a75a0060f0ed0b Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 22:25:36 +0000 Subject: [PATCH 23/42] docs(profile): requirement status options, status line vs holding meter --- .claude/plans/2026-10-29-profile-pages.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.claude/plans/2026-10-29-profile-pages.md b/.claude/plans/2026-10-29-profile-pages.md index d675504b2..fe037df3e 100644 --- a/.claude/plans/2026-10-29-profile-pages.md +++ b/.claude/plans/2026-10-29-profile-pages.md @@ -288,6 +288,26 @@ Rest states: - The Details card holds only "Minimum to chat". - Instagram and TikTok are drawn as if linked. They need backend fields and link flows (the proto has X only). +### D: requirement status (met / unmet), 2026-10-28 + +The question: can a viewer see at a glance whether they meet a public group's requirements? Section D grows to 2008×4666. +Every frame keeps "Open Chat"; the status replaces the "Your balance" footer. + +| Option | Met | Short | Split (join $50 / chat $100, hold $70) | Checking | +|---|---|---|---|---| +| A · status line | 868:4259 | 868:4424 | 868:4586 | 868:4779 | +| B · holding meter | 868:4945 | 868:5112 | 868:5274 | 868:5467 | + +Colour and treatment: +- **Met:** `money/positive` with Central CircleCheck. +- **Short:** "$N more" in `text/warning`, the rest `text/primary`. No error red. +- **B's short bar:** `text/primary` on a `control/fill` track; a met bar caps at 100%. +- **Checking:** `text/tertiary`, no tick. The gate fails open with no rate (`isProvisional`). + +**For the dev:** the verdict and shortfall must come from the existing chat gate (`ConversationGate` `unmetBalance` / +`balanceShortfall`, `GroupProfileState.swift:95`). The view never compares amounts. Option C, a "Buy $X to Join" CTA, +is parked because it changes what the button does. + ## 10. Taste calls after round 1 1. Avatar: right of the name (A), centred over the cover (B), or top-left on the card (C). From 796d7c5301ad16a9db3796bde6176c4816d28c99 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 22:41:51 +0000 Subject: [PATCH 24/42] docs(profile): immersive cover-derived colour for public groups --- .claude/plans/2026-10-29-profile-pages.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.claude/plans/2026-10-29-profile-pages.md b/.claude/plans/2026-10-29-profile-pages.md index fe037df3e..f077ed28b 100644 --- a/.claude/plans/2026-10-29-profile-pages.md +++ b/.claude/plans/2026-10-29-profile-pages.md @@ -308,6 +308,27 @@ Colour and treatment: `balanceShortfall`, `GroupProfileState.swift:95`). The view never compares amounts. Option C, a "Buy $X to Join" CTA, is parked because it changes what the button does. +### D: public group immersive colour (Aiden's reference: cooking-app recipe page), 2026-10-28 + +The cover melts into a deep colour derived from it, which becomes the whole page background. Secondary text takes a +light tint of the hue instead of grey, the surfaces are a lighter tint, and Share is an outline pill in the tint. The +CTA stays the white capsule, with its fade ramping into the page colour. Row 5 of section D (now 2008×5864). + +| Frame | id | Page / tint / surface | +|---|---|---| +| Blue · Met | 870:5124 | #0B1F4D / #A9C1F5 / #1F315B | +| Warm cover · Met | 870:5287 | #3A1606 / #F2C9A8 / #4A291A | +| Blue · Short | 870:5450 | as Blue; `text/warning` (#FFF383) reads on navy, "You hold" in tint | +| No cover (from avatar) | 870:5610 | #0C1C42, a radial glow of the logo blue behind the avatar | + +Hairlines are the tint at 20%. Every colour is a literal named "· derived from cover (literal)". + +**For the dev: the colour source.** The cover's blurhash already encodes its average colour (the DC term), so the colour +can be derived client-side with no new backend data. Clamp the lightness for contrast, e.g. OKLCH L ≈ 0.20–0.24. +With no cover, derive it from the avatar. + +**Known nit:** on the warm frame, the amber-to-umber melt is a little abrupt and shows a faint band. + ## 10. Taste calls after round 1 1. Avatar: right of the name (A), centred over the cover (B), or top-left on the card (C). From 67e805d10023dfae2306aab7b3485902c4aa0211 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 22:53:04 +0000 Subject: [PATCH 25/42] docs(profile): tailored chatting-recently options with host highlight --- .claude/plans/2026-10-29-profile-pages.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.claude/plans/2026-10-29-profile-pages.md b/.claude/plans/2026-10-29-profile-pages.md index f077ed28b..4640e8103 100644 --- a/.claude/plans/2026-10-29-profile-pages.md +++ b/.claude/plans/2026-10-29-profile-pages.md @@ -329,6 +329,24 @@ With no cover, derive it from the avatar. **Known nit:** on the warm frame, the amber-to-umber melt is a little abrupt and shows a faint band. +### D: "Chatting recently", tailored, with the host highlighted (2026-10-28) + +Row 6 of section D (now 2008×7218), on the blue immersive page. All three options use the hand badge plus a "Host" +pill. "22 members" is never tappable. + +| Option | id | Host distinction | +|---|---|---| +| 1 · Portrait carousel | 874:5553 | a taller featured first card (128×148) with the avatar ringed in the tint; others 88×112 with "2m ago"; the 4th card clips as the scroll hint | +| 2 · Activity list | 875:5674 | a pinned top row on a lighter surface (#2A3E6C), avatar 42; three recent rows; "+2 more this week · 22 members" | +| 3 · Pulse headline | 876:5789 | a "Hosted by **Ted Livingston**" line, then a facepile, "6 people chatted this week" and the recent names with times | + +Data: `SampleChatters` (host first; `user_profile`, `last_sent_at`, `is_creator`). The counts and recency are derived +client-side from `last_sent_at` (7-day window per the Oct 3 note), so the dev confirms them. Nothing invented: no +mutuals, presence or message previews. + +To fit, padding above the identity block was cut to 4 and the section-title padding to 12, which makes this row's +cover slightly shorter than row 5's. + ## 10. Taste calls after round 1 1. Avatar: right of the name (A), centred over the cover (B), or top-left on the card (C). From f8b9ca0366fd8f13ed29d18bbc381f5b05a09bcb Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 23:16:09 +0000 Subject: [PATCH 26/42] docs(profile): refined pulse, group bio and polish pass --- .claude/plans/2026-10-29-profile-pages.md | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.claude/plans/2026-10-29-profile-pages.md b/.claude/plans/2026-10-29-profile-pages.md index 4640e8103..adc4f8d55 100644 --- a/.claude/plans/2026-10-29-profile-pages.md +++ b/.claude/plans/2026-10-29-profile-pages.md @@ -347,6 +347,43 @@ mutuals, presence or message previews. To fit, padding above the identity block was cut to 4 and the section-title padding to 12, which makes this row's cover slightly shorter than row 5's. +### D: pulse refined, plus group bio, then a polish pass (2026-10-28) ★ Aiden: "by far the best yet" + +Row 8 of section D: Default 885:14427, Busy 885:14621, Quiet 885:14819, Nobody this week 885:15013. Row 7 (the refined +carousel 880:5920 / 880:6224 / 880:6528, plus the anatomy board 881:14237) is parked; Aiden prefers low-detail +participants. + +The layout, top to bottom: +1. Cover melting into the derived navy. +2. Avatar, name, "Public group · N members". +3. **Bio** (`Conversation.description`, at most 160 characters, white 16 regular). +4. The outline **Share** pill. +5. **Requirements:** the pass tile with status line A. +6. **Chatting recently:** the pulse card, made of a facepile (up to 5 faces at 36, −10 overlap, plus a "+N" bubble), the + headline "N chatting this week", the meta line "Last message 2m ago · 22 members", a hairline, and a footer row + "Hosted by **Ted Livingston** ›" (avatar 28 with the hand badge, 44 tap height). +7. Open Chat. + +Copy rules by state: +- **Busy:** "84 chatting this week" plus "+79". +- **Quiet:** singular, "1 chatting this week", so the copy must inflect. +- **Nobody this week:** "Quiet this week", no facepile, no bio. + +The bio isn't persisted locally, so it can arrive late after a cold start. Avoid a layout jump (dev). + +**Polish pass:** +- One spacing scale. Identity: avatar → name 12, name → meta 4, meta → bio 8, bio → Share 16. Sections: Share → title + 16, title → card 8, card → title 16. Requirement-tile hairlines have 8 above and below; pulse-card hairlines 12. +- A single outline Share (transparent, 1.5pt tint stroke). The duplicate share circle was removed; row 6's source still + has it. +- The "Chatting recently" title was restored. +- Tile and card padding is 16, with baselines aligned. +- Section titles stay at the kit's bound 14pt. + +**Open:** in Default and Quiet the pulse card ends at about y 805, so its "Hosted by" row sits behind Open Chat at +rest (it scrolls). Either accept the scroll, or move Share into the nav bar as a glass circle next to ⋯ (saves about +60pt and is control-layer glass). That's Aiden's call. + ## 10. Taste calls after round 1 1. Avatar: right of the name (A), centred over the cover (B), or top-left on the card (C). From 08ab39607fbe99672dc32c8b7446bd3e62b26c11 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 23:28:26 +0000 Subject: [PATCH 27/42] docs(profile): build spec for the public group profile in code --- .claude/plans/2026-10-29-profile-pages.md | 63 +++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/.claude/plans/2026-10-29-profile-pages.md b/.claude/plans/2026-10-29-profile-pages.md index adc4f8d55..a1d55b77f 100644 --- a/.claude/plans/2026-10-29-profile-pages.md +++ b/.claude/plans/2026-10-29-profile-pages.md @@ -384,6 +384,69 @@ The bio isn't persisted locally, so it can arrive late after a cold start. Avoid rest (it scrolls). Either accept the scroll, or move Share into the nav bar as a glass circle next to ⋯ (saves about 60pt and is control-layer glass). That's Aiden's call. +## 12. Code build 1: public group profile, D row 8 (2026-10-28) + +Aiden chose to let the "Hosted by" row scroll under the CTA. Tuning mode is on, with `Packages/TunerKit` and +`LocalTuner.swift` copied from the swipe worktree, all git-ignored. + +### Scope +- **Changes:** the public branch of `ChatProfileScreen` (`!isPrivate`) only. The private group, the user profile and + every view model are untouched. +- **Unchanged behaviour:** + - every action and destination: share/invite, Edit Group (only when `canEdit`), the currency row → `.currencyInfo`, + portrait → `.userProfile(…, .groupMember)`, Open Chat, Leave Chat; + - the origins and the pinned-bar logic; + - the `requirementsNamed` hold-back; + - accessibility ids; + - the analytics. + +### Colour: "Bleed", ported from `~/Developer/Recipes/Recipes/DesignSystem/Bleed/` +- **Location:** FlipcashUI. It's a package, so there are no pbxproj edits. +- **Files:** BleedRGB, OKLab, BleedColorExtractor (a `CGImage` entry point), BleedPalette, BleedTextStyle, EasedStops, + BleedFade, ObservableBox, BleedColorStore (keyed by a String id), and a modifier taking an id plus image. +- **Defaults** are the values baked in Recipes commit 6e2c13d: kmeans, sample 0.26, saturation 1.2, L dark 0.10, + pull 0.6, warmth 1.0, vibrant text 0.6, fade 183. The Recipes PLAN.md is stale, so don't use its values. +- **Source order:** + 1. the cover bitmap (`profileAvatars.data(for: .groupCover(id))`); + 2. the cover blurhash image (`BlurHashCache`); + 3. the avatar data or blurhash; + 4. otherwise the placeholder (today's black). +- Dark-mode palette only (the app is dark). +- **Tunables:** `// LOCAL-TUNER` lines under `Profile/Public/*` for saturation, L dark, pull, fade height and cover height. + +### Layout (top to bottom) +1. **Cover:** full-bleed, under the status bar, with the Bleed fade into `palette.background`. The page, scroll and + navigation container backgrounds all use `palette.background`, with `toolbarColorScheme` from the palette. +2. **Identity:** + - a left-aligned avatar overlapping the melt, with its ring in `palette.background`; + - the name, large and bold; + - "Public group · N members" (`rosterSummary.memberCount`) in the secondary style; + - the bio (`description`) in white. +3. **Actions:** an outline "Share" pill, 44 tall with a 1.5pt stroke in the secondary colour, doing today's + `isInviting` action. Edit Group (when `canEdit`) uses the same outline pill. The old 38pt circle goes. +4. **Section titles:** "Requirements" and "Chatting recently", 14 semibold, in the secondary style. +5. **Requirement pass tile:** `controlTint` surface, padding 16, with the amount as the hero. + - It reads "of ◉ " (the token icon + name, tappable → `.currencyInfo` exactly as today), then "to join and + chat". If chat ≠ join, there are two tiles side by side. + - **Footer:** today's "Your Balance $X" string, unchanged. The met/unmet status line is **parked**, because it + needs the gate verdict (affordability). +6. **Pulse card:** `controlTint` surface. + - Up to 5 faces at 36 with −10 overlap, each tappable to its profile as today, and a "+N" bubble. + - "N chatting this week" (sample chatters with `lastSentAt` inside 7 days), then "Last message · + N members". + - A hairline, then "Hosted by ›" (→ the creator's profile). + - With nobody chatting: "Quiet this week". +7. **Leave Chat** (members) at the end, unchanged in behaviour. +8. **Pinned CTA:** "Open Chat" in the branch's capsule primary style. On iOS 18 the backdrop fade uses + `palette.background`; on iOS 26 it's the system edge effect. + +### For the dev (in the report and the plan) +- The met/unmet status needs the gate verdict on the profile. +- "N chatting this week" counts a server sample of at most 100. +- Collapsing join == chat compares the two rule values. +- The member count is shown on public groups. +- The description arrives late after a cold start. + ## 10. Taste calls after round 1 1. Avatar: right of the name (A), centred over the cover (B), or top-left on the card (C). From 3c2db72e9df857ce07692b862869beefcadfc647 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 23:59:34 +0000 Subject: [PATCH 28/42] feat(ui): port the Bleed colour engine into FlipcashUI Derives a deep page colour and a text palette from an image, dark only, with the Recipes prototype's tuned defaults baked in. --- .../Theme/Bleed/BleedBackground.swift | 57 ++++ .../Theme/Bleed/BleedColorExtractor.swift | 184 +++++++++++++ .../Theme/Bleed/BleedColorStore.swift | 73 +++++ .../FlipcashUI/Theme/Bleed/BleedFade.swift | 46 ++++ .../FlipcashUI/Theme/Bleed/BleedPalette.swift | 252 ++++++++++++++++++ .../Theme/Bleed/BleedPaletteModifier.swift | 43 +++ .../FlipcashUI/Theme/Bleed/BleedRGB.swift | 66 +++++ .../Theme/Bleed/BleedSettings.swift | 44 +++ .../Theme/Bleed/BleedTextStyle.swift | 24 ++ .../FlipcashUI/Theme/Bleed/EasedStops.swift | 31 +++ .../FlipcashUI/Theme/Bleed/OKLab.swift | 95 +++++++ .../Theme/Bleed/ObservableBox.swift | 18 ++ 12 files changed, 933 insertions(+) create mode 100644 FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedBackground.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedColorExtractor.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedColorStore.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedFade.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedPalette.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedPaletteModifier.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedRGB.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedSettings.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedTextStyle.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Theme/Bleed/EasedStops.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Theme/Bleed/OKLab.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Theme/Bleed/ObservableBox.swift diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedBackground.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedBackground.swift new file mode 100644 index 000000000..0fdff02b3 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedBackground.swift @@ -0,0 +1,57 @@ +// +// BleedBackground.swift +// FlipcashUI +// + +import SwiftUI + +/// A container that resolves an image's bleed palette, paints `palette.background` behind its +/// content, and provides `\.bleedPalette` to the content. +public struct BleedBackground: View { + + let key: String? + let image: UIImage? + var settings: BleedSettings + var ignoresSafeArea: Bool + @ViewBuilder let content: Content + + /// A background colored from `image`, cached under `key`. + public init( + key: String?, + image: UIImage?, + settings: BleedSettings = .default, + ignoresSafeArea: Bool = true, + @ViewBuilder content: () -> Content + ) { + self.key = key + self.image = image + self.settings = settings + self.ignoresSafeArea = ignoresSafeArea + self.content = content() + } + + public var body: some View { + content + .background { + if ignoresSafeArea { + BleedFill().ignoresSafeArea() + } else { + BleedFill() + } + } + .bleedPalette(key: key, image: image, settings: settings) + } +} + +/// Fills its frame with the environment's `bleedPalette.background`. +public struct BleedFill: View { + + @Environment(\.bleedPalette) private var palette + + /// A fill of the palette's background. + public init() {} + + public var body: some View { + Rectangle().fill(palette.background) + } +} 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..6d1181f4c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedPalette.swift @@ -0,0 +1,252 @@ +// +// 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 + /// 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), + 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, + 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.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 lab = OKLab(background) + let text = BleedRGB.white + + // Flat text: the lowest opacity (from a floor that reads well) that keeps the contrast. + let secondaryOpacity = Self.flatOpacity(text, on: background, from: Self.secondaryOpacityFloor, minimumContrast: 4.5) + let tertiaryOpacity = Self.flatOpacity(text, on: background, from: Self.tertiaryOpacityFloor, minimumContrast: 3) + let bodyOpacity = Self.flatOpacity(text, on: background, from: Self.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)) + + // Vibrant text, kept only where it stays legible: 3:1 for secondary and tertiary metadata, + // 4.5:1 for body paragraphs; otherwise that role stays flat. + var vibrantSecondary: BleedTextStyle? + var vibrantTertiary: BleedTextStyle? + var vibrantBody: BleedTextStyle? + if settings.vibrantText { + let opacity = settings.vibrantOpacity + vibrantSecondary = Self.vibrantStyle(text, on: background, opacities: [opacity], minimumContrast: 3) + // Tertiary starts quieter than secondary and climbs back toward it until it reads. + vibrantTertiary = Self.vibrantStyle( + text, + on: background, + opacities: stride(from: opacity * Self.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 = Self.vibrantStyle( + text, + on: background, + opacities: stride(from: min(opacity + Self.vibrantBodyLift, 1), through: 1, by: 0.02), + minimumContrast: 4.5 + ) + } + + // 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: lab, text: text) + + self.init( + background: background.color, + primaryText: text.color, + secondaryText: flatSecondary.color, + bodyText: flatBody.color, + secondaryTextStyle: vibrantSecondary ?? flatSecondary, + tertiaryTextStyle: vibrantTertiary ?? flatTertiary, + bodyTextStyle: vibrantBody ?? flatBody, + hairline: text.color.opacity(0.18), + controlTint: tint.color, + controlLabel: text.color, + colorScheme: .dark, + backgroundRGB: background + ) + } + + // 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 } +} + +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 + } +} From f0c8e5408dc49b975a49fb7f4f423ff5cbf88fc7 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Wed, 28 Oct 2026 23:59:35 +0000 Subject: [PATCH 29/42] feat(profile): public group profile on a cover-derived page The public branch of ChatProfileScreen draws the cover melting into its own colour, an identity block with outline Share/Edit pills, requirement tiles, a chatting-recently pulse card and a capsule Open Chat. The private branch is unchanged. --- .../Screens/Profile/ChatProfileScreen.swift | 273 +++++++++---- .../Screens/Profile/PublicGroupCards.swift | 258 +++++++++++++ .../Screens/Profile/PublicGroupCopy.swift | 139 +++++++ .../Screens/Profile/PublicGroupLook.swift | 89 +++++ .../Profile/PublicGroupProfileView.swift | 363 ++++++++++++++++++ FlipcashTests/PublicGroupCopyTests.swift | 139 +++++++ 6 files changed, 1180 insertions(+), 81 deletions(-) create mode 100644 Flipcash/Core/Screens/Profile/PublicGroupCards.swift create mode 100644 Flipcash/Core/Screens/Profile/PublicGroupCopy.swift create mode 100644 Flipcash/Core/Screens/Profile/PublicGroupLook.swift create mode 100644 Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift create mode 100644 FlipcashTests/PublicGroupCopyTests.swift diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 10a866bd0..d5c80dbe2 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -133,6 +133,105 @@ struct ChatProfileScreen: View { // MARK: - Body - var body: some View { + screen + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + overflowMenu + } + } + .dialog(item: $dialogItem) + .fullScreenCover(isPresented: $isInviting) { + ShareToChatsSheet(subject: .group(conversationID), isPresented: $isInviting) { chatID in + router.push(.tipConversation(chatID)) + } + } + .tray(isPresented: $isPickingMuteDuration) { + MuteChatSheet(conversationID: conversationID, isPresented: $isPickingMuteDuration) + } + .tray(isPresented: $isShowingE2ee) { + E2eeLearnMoreSheet(kind: isPrivate ? .privateGroup : .group, isPresented: $isShowingE2ee) + } + .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. + @ViewBuilder + private var screen: some View { + if isPrivate { + privateScreen + } else { + publicScreen + } + } + + /// The shared profile layout, drawn for a private group. + private var privateScreen: some View { Background(color: .backgroundMain) { ScrollView { VStack(spacing: 0) { @@ -227,89 +326,101 @@ struct ChatProfileScreen: View { pinnedActions .toastClearance(toasts) } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - overflowMenu - } - } - .dialog(item: $dialogItem) - .fullScreenCover(isPresented: $isInviting) { - ShareToChatsSheet(subject: .group(conversationID), isPresented: $isInviting) { chatID in - router.push(.tipConversation(chatID)) - } - } - .tray(isPresented: $isPickingMuteDuration) { - MuteChatSheet(conversationID: conversationID, isPresented: $isPickingMuteDuration) - } - .tray(isPresented: $isShowingE2ee) { - E2eeLearnMoreSheet(kind: isPrivate ? .privateGroup : .group, isPresented: $isShowingE2ee) - } - .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 + } + + // 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)) + }, + 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") + .accessibilityIdentifier("chat-profile-share") + }, + footer: { + if isMember { + leaveButton + .padding(.horizontal, PublicGroupProfileView.inset) } + }, + pinned: { + publicPinnedActions + .toastClearance(toasts) } - mintMetadata = metadata - } - .task(id: namedMints) { - mintNamesTimedOut = false - try? await Task.sleep(for: .seconds(1.5)) - guard !Task.isCancelled else { return } - mintNamesTimedOut = true + ) + .bleedPalette(key: source?.key, image: source?.image, settings: PublicGroupLook.settings) + .task(id: conversation?.coverPicture?.blobID) { + await sessionContainer.profileAvatars.load(.groupCover(conversationID), picture: conversation?.coverPicture) + } + } + + private func publicContent(cover: UIImage?) -> PublicGroupProfileContent { + let named = requirements.flatMap { requirementsNamed ? $0 : nil } + 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: named.map { PublicGroupRequirementTile.tiles(for: $0, metadata: mintMetadata) } ?? [], + yourBalance: named.flatMap { yourBalance(in: $0) }, + pulse: chatters.isEmpty ? nil : PublicGroupPulse(chatters: chatters, now: .now), + host: chatters.first(where: \.isCreator).map(GroupPortrait.init) + ) + } + + /// 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) } } diff --git a/Flipcash/Core/Screens/Profile/PublicGroupCards.swift b/Flipcash/Core/Screens/Profile/PublicGroupCards.swift new file mode 100644 index 000000000..078e1e69f --- /dev/null +++ b/Flipcash/Core/Screens/Profile/PublicGroupCards.swift @@ -0,0 +1,258 @@ +// +// 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] + let yourBalance: String? + let onToken: (PublicGroupToken) -> Void + + @Environment(\.bleedPalette) private var palette + + var body: some View { + if tiles.count == 1, let tile = tiles.first { + VStack(alignment: .leading, spacing: 0) { + tileContent(tile, isWide: true) + if let yourBalance { + Rectangle() + .fill(palette.hairline) + .frame(height: 1) + .padding(.vertical, 8) + footer(yourBalance) + } + } + .padding(Self.padding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(palette.controlTint, in: surface) + } else { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 8) { + ForEach(tiles) { tile in + tileContent(tile, isWide: false) + .padding(Self.padding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(palette.controlTint, in: surface) + } + } + if let yourBalance { + footer(yourBalance) + .padding(.horizontal, Self.padding) + } + } + } + } + + 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.token) + } + } else { + amount(tile) + tokenButton(tile.token) + } + Text(tile.purpose.caption) + .font(.appTextSmall) + .foregroundStyle(palette.secondaryStyle) + } + } + + private func amount(_ tile: PublicGroupRequirementTile) -> some View { + Text(tile.amount) + .font(.appDisplayMedium) + .foregroundStyle(palette.primaryText) + .accessibilityLabel("\(tile.amount) \(tile.purpose.caption)") + .accessibilityIdentifier(tile.purpose == .chat ? "group-profile-chat-minimum" : "group-profile-join-minimum") + } + + @ViewBuilder + private func tokenButton(_ token: PublicGroupToken?) -> some View { + if let token { + Button { + onToken(token) + } label: { + HStack(spacing: 6) { + Text("of") + .font(.appTextMedium) + .foregroundStyle(palette.secondaryStyle) + TokenIconWithName( + url: token.imageURL, + monogramID: token.mint.base58, + name: token.name, + iconSize: 16 + ) + } + .contentShape(Rectangle()) + } + .buttonStyle(PublicGroupDimButtonStyle()) + .accessibilityIdentifier("group-profile-token") + } + } + + private func footer(_ yourBalance: String) -> some View { + HStack { + Text("Your Balance") + Spacer() + Text(yourBalance) + } + .font(.appTextSmall) + .foregroundStyle(palette.secondaryStyle) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("group-profile-your-balance") + } + + private var surface: RoundedRectangle { + RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous) + } + + private static var padding: CGFloat { 16 } +} + +/// Who has been chatting in a public group lately: a facepile, 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 + + @Environment(\.bleedPalette) private var palette + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if !pulse.faces.isEmpty { + facepile + .padding(.bottom, 12) + } + + Text(PublicGroupCopy.chatting(count: pulse.chattingCount)) + .font(.appTextMedium) + .foregroundStyle(palette.primaryText) + + if let meta = PublicGroupCopy.pulseMeta(lastMessageAt: pulse.lastMessageAt, memberCount: memberCount, now: .now) { + Text(meta) + .font(.appTextCaption) + .foregroundStyle(palette.secondaryStyle) + .padding(.top, 2) + } + + if let host { + Rectangle() + .fill(palette.hairline) + .frame(height: 1) + .padding(.top, 12) + .padding(.bottom, 12) + hostRow(host) + } + } + .padding(.top, Self.padding) + .padding(.horizontal, Self.padding) + .padding(.bottom, host == nil ? Self.padding : 0) + .frame(maxWidth: .infinity, alignment: .leading) + .background(palette.controlTint, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("group-profile-chatting") + } + + // MARK: - Facepile - + + private var facepile: some View { + HStack(spacing: -Self.faceOverlap) { + ForEach(pulse.faces, id: \.userID) { portrait in + Button { + onSelect(portrait.userID) + } label: { + 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) } + } + .buttonStyle(PublicGroupDimButtonStyle()) + .accessibilityLabel(portrait.name) + .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) } + .accessibilityLabel("\(pulse.overflow) more") + } + } + } + + // MARK: - Host - + + private func hostRow(_ host: GroupPortrait) -> some View { + Button { + onSelect(host.userID) + } label: { + HStack(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.secondaryStyle) + Text(host.name) + .foregroundStyle(palette.primaryText) + .lineLimit(1) + } + .font(.appTextSmall) + + Spacer(minLength: 8) + + Image.icon(.chevronRightMedium) + .iconSize(16) + .foregroundStyle(palette.secondaryStyle) + } + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .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 padding: CGFloat { 16 } + private static var faceSize: CGFloat { 36 } + private static var faceOverlap: CGFloat { 10 } + 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..b33ce7c57 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/PublicGroupCopy.swift @@ -0,0 +1,139 @@ +// +// 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 { + let mint: PublicKey + let name: String + 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: "$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`: one reading "to join and chat" when both rules are the same + /// amount in the same tokens, else one per rule. + static func tiles(for requirements: GroupBalanceRequirements, metadata: [PublicKey: StoredMintMetadata]) -> [PublicGroupRequirementTile] { + if let join = requirements.join, join == requirements.chat { + return [tile(.joinAndChat, join, metadata)] + } + var tiles: [PublicGroupRequirementTile] = [] + if let join = requirements.join { tiles.append(tile(.join, join, metadata)) } + if let chat = requirements.chat { tiles.append(tile(.chat, chat, metadata)) } + return tiles + } + + private static func tile(_ purpose: Purpose, _ requirement: MinimumBalanceRequirement, _ metadata: [PublicKey: StoredMintMetadata]) -> 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, + amount: requirement.amount.formattedDroppingZeroFraction(), + token: token + ) + } +} diff --git a/Flipcash/Core/Screens/Profile/PublicGroupLook.swift b/Flipcash/Core/Screens/Profile/PublicGroupLook.swift new file mode 100644 index 000000000..6721678da --- /dev/null +++ b/Flipcash/Core/Screens/Profile/PublicGroupLook.swift @@ -0,0 +1,89 @@ +// +// PublicGroupLook.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The tuned 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 var saturation: Double { Tuner.shared.value("Profile/Public/Saturation", default: 1.2, in: 0...2, step: 0.01, unit: .multiplier) } // LOCAL-TUNER + + /// The OKLab lightness the page is pulled toward. + static var lightnessDark: Double { Tuner.shared.value("Profile/Public/L dark", default: 0.1, in: 0...0.4, step: 0.01) } // LOCAL-TUNER + + /// How far toward ``lightnessDark`` the cover's color moves. + static var lightnessPull: Double { Tuner.shared.value("Profile/Public/Pull", default: 0.6, in: 0...1, step: 0.01) } // LOCAL-TUNER + + /// The length of the melt from the cover into the page. + static var fadeHeight: CGFloat { Tuner.shared.value("Profile/Public/Fade height", default: CGFloat(183), in: 0...320, step: 1, unit: .points) } // LOCAL-TUNER + + /// The cover's height from the top of the screen, under the status and navigation bars. + static var coverHeight: CGFloat { Tuner.shared.value("Profile/Public/Cover height", default: CGFloat(274), in: 160...420, step: 1, unit: .points) } // LOCAL-TUNER + + /// 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..abef28ea1 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift @@ -0,0 +1,363 @@ +// +// PublicGroupProfileView.swift +// Flipcash +// + +import SwiftUI +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 + let title: String + let memberCount: UInt64? + /// The group's bio; it isn't persisted locally, so it can arrive after the rest. + let description: String? + let avatarImageData: Data? + 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] + /// 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, what it takes to join and chat, and who has been chatting. +/// +/// 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 + let portraitData: (UserID) -> Data? + let loadPortrait: (GroupPortrait) async -> Void + let onSelectPerson: (UserID) -> Void + 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 + /// Ends the scroll, such as Leave Chat. + @ViewBuilder let footer: () -> Footer + /// The bar pinned under the scroll. + @ViewBuilder let pinned: () -> Pinned + + @Environment(\.bleedPalette) private var palette + + /// 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 !content.requirements.isEmpty { + section("Requirements") { + PublicGroupRequirementsView( + tiles: content.requirements, + yourBalance: content.yourBalance, + onToken: onToken + ) + } + } + + if let pulse = content.pulse { + section("Chatting recently") { + PublicGroupPulseCard( + pulse: pulse, + host: content.host, + memberCount: content.memberCount, + portraitData: portraitData, + loadPortrait: loadPortrait, + onSelect: onSelectPerson + ) + } + } + + footer() + .padding(.top, 8) + } + .padding(.top, 4) + .padding(.bottom, 24) + .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) + } + + // MARK: - Cover - + + private var cover: some View { + Color.clear + .frame(maxWidth: .infinity) + .frame(height: PublicGroupLook.coverHeight) + .overlay { coverImage } + .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: min(PublicGroupLook.fadeHeight, PublicGroupLook.coverHeight)) + } + .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(Self.avatarRing) + .background(palette.background, in: Circle()) + + accessory() + + Spacer(minLength: 0) + } + + Text(content.title) + .font(.appTitleLarge) + .foregroundStyle(palette.primaryText) + .multilineTextAlignment(.leading) + .padding(.top, 12) + .accessibilityIdentifier("profile-name") + + Text(PublicGroupCopy.summary(memberCount: content.memberCount)) + .font(.appTextSmall) + .foregroundStyle(palette.secondaryStyle) + .padding(.top, 4) + + if let description = content.description, !description.isEmpty { + Text(description) + .font(.appTextMessage) + .foregroundStyle(palette.primaryText) + .multilineTextAlignment(.leading) + .padding(.top, 8) + .transition(.opacity) + .accessibilityIdentifier("profile-bio") + } + + HStack(spacing: 12) { + actions() + } + .padding(.top, 16) + } + .animation(.easeOut(duration: 0.2), value: content.description) + .padding(.horizontal, Self.inset) + } + + private func section(_ title: String, @ViewBuilder card: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.appTextSmall) + .foregroundStyle(palette.secondaryStyle) + .accessibilityAddTraits(.isHeader) + card() + } + .padding(.horizontal, Self.inset) + .padding(.top, 16) + } + + /// The side inset the page sits on. + static var inset: CGFloat { 20 } + private static var avatarSize: CGFloat { 72 } + private static var avatarRing: CGFloat { 3 } +} + +// MARK: - Buttons - + +/// The outline pill under the bio: a capsule stroked in the palette's secondary color. +struct PublicGroupOutlineButtonStyle: ButtonStyle { + + @Environment(\.bleedPalette) private var palette + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.appTextMedium) + .foregroundStyle(palette.secondaryStyle) + .padding(.horizontal, 24) + .frame(height: Metrics.buttonHeightThin) + .overlay { Capsule().strokeBorder(palette.secondaryStyle, lineWidth: 1.5) } + .contentShape(Capsule()) + .opacity(configuration.isPressed ? 0.7 : 1) + .animation(.easeInOut(duration: 0.1), value: 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()) + .opacity(configuration.isPressed ? 0.7 : 1) + .animation(.easeInOut(duration: 0.1), value: 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 } +} + +/// A press that dims its label, for a tappable row or face with no chrome of its own. +struct PublicGroupDimButtonStyle: ButtonStyle { + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .opacity(configuration.isPressed ? 0.7 : 1) + .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) + } +} + +// 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)], + 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 }, + onToken: { _ in }, + accessory: { EmptyView() }, + actions: { + Button("Share") {} + .buttonStyle(PublicGroupOutlineButtonStyle()) + }, + footer: { EmptyView() }, + 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 diff --git a/FlipcashTests/PublicGroupCopyTests.swift b/FlipcashTests/PublicGroupCopyTests.swift new file mode 100644 index 000000000..415330ae2 --- /dev/null +++ b/FlipcashTests/PublicGroupCopyTests.swift @@ -0,0 +1,139 @@ +// +// 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 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: [:]) + + #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: [:]).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: [:]) + + #expect(tiles.map(\.purpose) == [.join, .chat]) + #expect(tiles.map(\.amount) == ["$50", "$100"]) + } +} From f799306f31f638ead354813df63cb30dd8dd1dad Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Thu, 29 Oct 2026 09:27:28 +0000 Subject: [PATCH 30/42] fix(profile): design review fold-in for the public group profile --- .../Screens/Profile/ChatProfileScreen.swift | 18 ++-- .../Screens/Profile/PublicGroupCards.swift | 72 ++++++++++----- .../Screens/Profile/PublicGroupCopy.swift | 3 + .../Profile/PublicGroupProfileView.swift | 63 +++++++++++-- .../Theme/Bleed/BleedBackground.swift | 57 ------------ .../FlipcashUI/Theme/Bleed/BleedPalette.swift | 90 ++++++++++++------- 6 files changed, 176 insertions(+), 127 deletions(-) delete mode 100644 FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedBackground.swift diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index d5c80dbe2..d38a7dd85 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 @@ -220,10 +221,11 @@ struct ChatProfileScreen: View { } } - /// A public group gets its cover-colored page; a private group keeps the shared profile layout. + /// A public group gets its cover-colored page; a private group keeps the shared profile layout, + /// as does a group not yet loaded, so a private one never paints as public. @ViewBuilder private var screen: some View { - if isPrivate { + if isPrivate || conversation == nil { privateScreen } else { publicScreen @@ -370,6 +372,7 @@ struct ChatProfileScreen: View { } .buttonStyle(PublicGroupOutlineButtonStyle()) .accessibilityLabel("Share group") + .accessibilityInputLabels(["Share"]) .accessibilityIdentifier("chat-profile-share") }, footer: { @@ -401,7 +404,8 @@ struct ChatProfileScreen: View { cover: cover, requirements: named.map { PublicGroupRequirementTile.tiles(for: $0, metadata: mintMetadata) } ?? [], yourBalance: named.flatMap { yourBalance(in: $0) }, - pulse: chatters.isEmpty ? nil : PublicGroupPulse(chatters: chatters, now: .now), + // Held back with the requirements above it, so a late reveal doesn't push the card down. + pulse: chatters.isEmpty || !requirementsNamed ? nil : PublicGroupPulse(chatters: chatters, now: .now), host: chatters.first(where: \.isCreator).map(GroupPortrait.init) ) } diff --git a/Flipcash/Core/Screens/Profile/PublicGroupCards.swift b/Flipcash/Core/Screens/Profile/PublicGroupCards.swift index 078e1e69f..3a9cddb8f 100644 --- a/Flipcash/Core/Screens/Profile/PublicGroupCards.swift +++ b/Flipcash/Core/Screens/Profile/PublicGroupCards.swift @@ -23,11 +23,9 @@ struct PublicGroupRequirementsView: View { VStack(alignment: .leading, spacing: 0) { tileContent(tile, isWide: true) if let yourBalance { - Rectangle() - .fill(palette.hairline) - .frame(height: 1) + PublicGroupHairline() .padding(.vertical, 8) - footer(yourBalance) + footer(yourBalance, style: palette.controlSecondaryStyle) } } .padding(Self.padding) @@ -44,7 +42,7 @@ struct PublicGroupRequirementsView: View { } } if let yourBalance { - footer(yourBalance) + footer(yourBalance, style: palette.secondaryStyle) .padding(.horizontal, Self.padding) } } @@ -56,15 +54,17 @@ struct PublicGroupRequirementsView: View { if isWide { HStack(alignment: .firstTextBaseline, spacing: 8) { amount(tile) - tokenButton(tile.token) + tokenButton(tile.token, purpose: tile.purpose) } } else { amount(tile) - tokenButton(tile.token) + tokenButton(tile.token, purpose: tile.purpose) } Text(tile.purpose.caption) .font(.appTextSmall) - .foregroundStyle(palette.secondaryStyle) + .foregroundStyle(palette.controlSecondaryStyle) + // The amount's label already reads it. + .accessibilityHidden(true) } } @@ -72,12 +72,14 @@ struct PublicGroupRequirementsView: View { Text(tile.amount) .font(.appDisplayMedium) .foregroundStyle(palette.primaryText) + .lineLimit(1) + .minimumScaleFactor(0.5) .accessibilityLabel("\(tile.amount) \(tile.purpose.caption)") .accessibilityIdentifier(tile.purpose == .chat ? "group-profile-chat-minimum" : "group-profile-join-minimum") } @ViewBuilder - private func tokenButton(_ token: PublicGroupToken?) -> some View { + private func tokenButton(_ token: PublicGroupToken?, purpose: PublicGroupRequirementTile.Purpose) -> some View { if let token { Button { onToken(token) @@ -85,7 +87,7 @@ struct PublicGroupRequirementsView: View { HStack(spacing: 6) { Text("of") .font(.appTextMedium) - .foregroundStyle(palette.secondaryStyle) + .foregroundStyle(palette.controlSecondaryStyle) TokenIconWithName( url: token.imageURL, monogramID: token.mint.base58, @@ -93,21 +95,33 @@ struct PublicGroupRequirementsView: View { iconSize: 16 ) } + // Grown to a 44pt target without moving anything. + .padding(.vertical, 12) .contentShape(Rectangle()) + .padding(.vertical, -12) } .buttonStyle(PublicGroupDimButtonStyle()) - .accessibilityIdentifier("group-profile-token") + .accessibilityIdentifier(Self.tokenIdentifier(for: purpose)) } } - private func footer(_ yourBalance: String) -> some View { + /// 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 { HStack { Text("Your Balance") Spacer() Text(yourBalance) } .font(.appTextSmall) - .foregroundStyle(palette.secondaryStyle) + .foregroundStyle(style) .accessibilityElement(children: .combine) .accessibilityIdentifier("group-profile-your-balance") } @@ -146,16 +160,13 @@ struct PublicGroupPulseCard: View { if let meta = PublicGroupCopy.pulseMeta(lastMessageAt: pulse.lastMessageAt, memberCount: memberCount, now: .now) { Text(meta) .font(.appTextCaption) - .foregroundStyle(palette.secondaryStyle) + .foregroundStyle(palette.controlSecondaryStyle) .padding(.top, 2) } if let host { - Rectangle() - .fill(palette.hairline) - .frame(height: 1) - .padding(.top, 12) - .padding(.bottom, 12) + PublicGroupHairline() + .padding(.vertical, 12) hostRow(host) } } @@ -172,7 +183,9 @@ struct PublicGroupPulseCard: View { private var facepile: some View { HStack(spacing: -Self.faceOverlap) { - ForEach(pulse.faces, id: \.userID) { portrait in + ForEach(Array(pulse.faces.enumerated()), id: \.element.userID) { index, portrait in + // The next face covers the last 10pt of this one, so only the rest is this face's. + let isCovered = index < pulse.faces.count - 1 || pulse.overflow > 0 Button { onSelect(portrait.userID) } label: { @@ -184,6 +197,10 @@ struct PublicGroupPulseCard: View { size: Self.faceSize ) .overlay { Circle().strokeBorder(palette.controlTint, lineWidth: 2) } + // Grown to a 44pt-tall target without moving anything, and cut to the visible part. + .padding(.vertical, (Self.hitHeight - Self.faceSize) / 2) + .contentShape(FaceHitShape(visibleWidth: isCovered ? Self.faceSize - Self.faceOverlap : Self.faceSize)) + .padding(.vertical, -(Self.hitHeight - Self.faceSize) / 2) } .buttonStyle(PublicGroupDimButtonStyle()) .accessibilityLabel(portrait.name) @@ -219,7 +236,7 @@ struct PublicGroupPulseCard: View { HStack(spacing: 4) { Text("Hosted by") - .foregroundStyle(palette.secondaryStyle) + .foregroundStyle(palette.controlSecondaryStyle) Text(host.name) .foregroundStyle(palette.primaryText) .lineLimit(1) @@ -230,7 +247,7 @@ struct PublicGroupPulseCard: View { Image.icon(.chevronRightMedium) .iconSize(16) - .foregroundStyle(palette.secondaryStyle) + .foregroundStyle(palette.controlSecondaryStyle) } .frame(minHeight: 44) .contentShape(Rectangle()) @@ -254,5 +271,16 @@ struct PublicGroupPulseCard: View { private static var padding: CGFloat { 16 } private static var faceSize: CGFloat { 36 } private static var faceOverlap: CGFloat { 10 } + private static var hitHeight: CGFloat { 44 } private static var hostSize: CGFloat { 28 } } + +/// A face's tap area: the full height of the rect, but only the width the next face doesn't cover. +nonisolated private struct FaceHitShape: Shape { + + let visibleWidth: CGFloat + + func path(in rect: CGRect) -> Path { + Path(CGRect(x: rect.minX, y: rect.minY, width: visibleWidth, height: rect.height)) + } +} diff --git a/Flipcash/Core/Screens/Profile/PublicGroupCopy.swift b/Flipcash/Core/Screens/Profile/PublicGroupCopy.swift index b33ce7c57..14888378b 100644 --- a/Flipcash/Core/Screens/Profile/PublicGroupCopy.swift +++ b/Flipcash/Core/Screens/Profile/PublicGroupCopy.swift @@ -83,8 +83,11 @@ nonisolated struct PublicGroupPulse: Equatable { /// 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? } diff --git a/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift b/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift index abef28ea1..d9cdc4b59 100644 --- a/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift +++ b/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift @@ -12,11 +12,15 @@ 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? @@ -40,9 +44,13 @@ struct PublicGroupProfileView Data? + /// Loads a person's avatar. let loadPortrait: (GroupPortrait) async -> Void + /// Opens a person's profile. let onSelectPerson: (UserID) -> 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 @@ -54,6 +62,7 @@ struct PublicGroupProfileView 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 @@ -91,6 +100,8 @@ struct PublicGroupProfileView 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 capsule stroked in the palette's secondary color. struct PublicGroupOutlineButtonStyle: ButtonStyle { @@ -235,11 +270,10 @@ struct PublicGroupOutlineButtonStyle: ButtonStyle { .font(.appTextMedium) .foregroundStyle(palette.secondaryStyle) .padding(.horizontal, 24) - .frame(height: Metrics.buttonHeightThin) + .frame(minHeight: Metrics.buttonHeightThin) .overlay { Capsule().strokeBorder(palette.secondaryStyle, lineWidth: 1.5) } .contentShape(Capsule()) - .opacity(configuration.isPressed ? 0.7 : 1) - .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) + .modifier(PublicGroupPress(isPressed: configuration.isPressed)) } } @@ -256,8 +290,7 @@ struct PublicGroupCapsuleButtonStyle: ButtonStyle { .frame(height: Self.height) .background(isEnabled ? Color.action : Color.action.opacity(0.1), in: Capsule()) .contentShape(Capsule()) - .opacity(configuration.isPressed ? 0.7 : 1) - .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) + .modifier(PublicGroupPress(isPressed: configuration.isPressed)) } /// The capsule's height, matching the amount screen's action button. @@ -267,13 +300,25 @@ struct PublicGroupCapsuleButtonStyle: ButtonStyle { static var sideInset: CGFloat { 32 } } -/// A press that dims its label, for a tappable row or face with no chrome of its own. +/// 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 - .opacity(configuration.isPressed ? 0.7 : 1) - .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) + .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) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedBackground.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedBackground.swift deleted file mode 100644 index 0fdff02b3..000000000 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedBackground.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// BleedBackground.swift -// FlipcashUI -// - -import SwiftUI - -/// A container that resolves an image's bleed palette, paints `palette.background` behind its -/// content, and provides `\.bleedPalette` to the content. -public struct BleedBackground: View { - - let key: String? - let image: UIImage? - var settings: BleedSettings - var ignoresSafeArea: Bool - @ViewBuilder let content: Content - - /// A background colored from `image`, cached under `key`. - public init( - key: String?, - image: UIImage?, - settings: BleedSettings = .default, - ignoresSafeArea: Bool = true, - @ViewBuilder content: () -> Content - ) { - self.key = key - self.image = image - self.settings = settings - self.ignoresSafeArea = ignoresSafeArea - self.content = content() - } - - public var body: some View { - content - .background { - if ignoresSafeArea { - BleedFill().ignoresSafeArea() - } else { - BleedFill() - } - } - .bleedPalette(key: key, image: image, settings: settings) - } -} - -/// Fills its frame with the environment's `bleedPalette.background`. -public struct BleedFill: View { - - @Environment(\.bleedPalette) private var palette - - /// A fill of the palette's background. - public init() {} - - public var body: some View { - Rectangle().fill(palette.background) - } -} diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedPalette.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedPalette.swift index 6d1181f4c..9b9406485 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedPalette.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Bleed/BleedPalette.swift @@ -23,6 +23,7 @@ public struct BleedPalette: Hashable { 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`. @@ -46,6 +47,7 @@ public struct BleedPalette: Hashable { secondaryTextStyle: BleedTextStyle(color: .textSecondary), tertiaryTextStyle: BleedTextStyle(color: .textTertiary), bodyTextStyle: BleedTextStyle(color: .textMain), + controlSecondaryTextStyle: BleedTextStyle(color: .textSecondary), hairline: .rowSeparator, controlTint: .backgroundRow, controlLabel: .textMain, @@ -62,6 +64,7 @@ public struct BleedPalette: Hashable { secondaryTextStyle: BleedTextStyle, tertiaryTextStyle: BleedTextStyle, bodyTextStyle: BleedTextStyle, + controlSecondaryTextStyle: BleedTextStyle, hairline: Color, controlTint: Color, controlLabel: Color, @@ -75,6 +78,7 @@ public struct BleedPalette: Hashable { self.secondaryTextStyle = secondaryTextStyle self.tertiaryTextStyle = tertiaryTextStyle self.bodyTextStyle = bodyTextStyle + self.controlSecondaryTextStyle = controlSecondaryTextStyle self.hairline = hairline self.controlTint = controlTint self.controlLabel = controlLabel @@ -85,58 +89,77 @@ public struct BleedPalette: Hashable { /// Builds the palette from an extracted bleed color. init(base: BleedRGB, settings: BleedSettings = .default) { let background = Self.adjustedBackground(base, settings: settings) - let lab = OKLab(background) let text = BleedRGB.white + let onBackground = Self.textStyles(text, on: background, settings: settings) - // Flat text: the lowest opacity (from a floor that reads well) that keeps the contrast. - let secondaryOpacity = Self.flatOpacity(text, on: background, from: Self.secondaryOpacityFloor, minimumContrast: 4.5) - let tertiaryOpacity = Self.flatOpacity(text, on: background, from: Self.tertiaryOpacityFloor, minimumContrast: 3) - let bodyOpacity = Self.flatOpacity(text, on: background, from: Self.bodyOpacityFloor, step: 0.05, minimumContrast: 4.5) + // 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)) - // Vibrant text, kept only where it stays legible: 3:1 for secondary and tertiary metadata, - // 4.5:1 for body paragraphs; otherwise that role stays flat. var vibrantSecondary: BleedTextStyle? var vibrantTertiary: BleedTextStyle? var vibrantBody: BleedTextStyle? if settings.vibrantText { let opacity = settings.vibrantOpacity - vibrantSecondary = Self.vibrantStyle(text, on: background, opacities: [opacity], minimumContrast: 3) + vibrantSecondary = vibrantStyle(text, on: surface, opacities: [opacity], minimumContrast: 3) // Tertiary starts quieter than secondary and climbs back toward it until it reads. - vibrantTertiary = Self.vibrantStyle( + vibrantTertiary = vibrantStyle( text, - on: background, - opacities: stride(from: opacity * Self.vibrantTertiaryScale, to: opacity, by: 0.02) + [opacity], + 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 = Self.vibrantStyle( + vibrantBody = vibrantStyle( text, - on: background, - opacities: stride(from: min(opacity + Self.vibrantBodyLift, 1), through: 1, by: 0.02), + on: surface, + opacities: stride(from: min(opacity + vibrantBodyLift, 1), through: 1, by: 0.02), minimumContrast: 4.5 ) } - - // 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: lab, text: text) - - self.init( - background: background.color, - primaryText: text.color, - secondaryText: flatSecondary.color, - bodyText: flatBody.color, - secondaryTextStyle: vibrantSecondary ?? flatSecondary, - tertiaryTextStyle: vibrantTertiary ?? flatTertiary, - bodyTextStyle: vibrantBody ?? flatBody, - hairline: text.color.opacity(0.18), - controlTint: tint.color, - controlLabel: text.color, - colorScheme: .dark, - backgroundRGB: background + return TextStyles( + secondary: vibrantSecondary ?? flatSecondary, + tertiary: vibrantTertiary ?? flatTertiary, + body: vibrantBody ?? flatBody, + flatSecondary: flatSecondary, + flatBody: flatBody ) } @@ -244,6 +267,9 @@ extension BleedPalette { 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 { From 77ad7bb2710103af5e9dc45b09af0c0e60b03872 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Thu, 29 Oct 2026 09:29:08 +0000 Subject: [PATCH 31/42] docs(profile): code build 1 result and open calls --- .claude/plans/2026-10-29-profile-pages.md | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.claude/plans/2026-10-29-profile-pages.md b/.claude/plans/2026-10-29-profile-pages.md index a1d55b77f..3d622e59d 100644 --- a/.claude/plans/2026-10-29-profile-pages.md +++ b/.claude/plans/2026-10-29-profile-pages.md @@ -447,6 +447,40 @@ Aiden chose to let the "Hosted by" row scroll under the CTA. Tuning mode is on, - The member count is shown on public groups. - The description arrives late after a cold start. +### Code build 1: result (2026-10-29) + +Commits on `feat/ui-profile` (local only): +- `3c2db72e` the Bleed port (FlipcashUI/Theme/Bleed); +- `f0c8e540` the public group profile; +- `f799306f` the design-review fold-in. + +The build passes and all 46 profile tests pass, including the new `PublicGroupCopyTests`. `privateScreen` is identical to +before. + +**Fold-in:** +- A still-loading group renders as private, so a private group never paints as "Public group". +- Faces and the token link get 44pt hit areas. +- Text on tiles is contrast-solved on the control tint (`controlSecondaryStyle`). +- The pulse card is held back with the requirements. +- The bio animation is page-wide and respects Reduce Motion. +- Presses scale to 0.96 with `Haptics.soft()` on touch-down. +- Hairlines are one device pixel. + +**Tunables:** `Profile/Public/{Saturation, L dark, Pull, Fade height, Cover height}`. They must be baked before any PR, +because CI can't see `LocalTuner.swift`. + +**Taste calls still open** (from the code critique): +1. "+N" isn't tappable; the old grid opened every sampled chatter. +2. Leave Chat now follows the content. +3. A 33pt title next to a 40pt amount (layout rule 2). +4. First paint fades from #19191A to the cover colour over 0.25s; the colour could be seeded from the blurhash instead. + +**Parked for the dev / system work:** +- The met/unmet status line. +- One shared capsule CTA style (the screen button height is still open). +- A Bleed row in the standard. +- Device checks: iOS 18 opaque nav bar banding; iOS 26 opened-from-chat with no bottom fade; AX5; long token names. + ## 10. Taste calls after round 1 1. Avatar: right of the name (A), centred over the cover (B), or top-left on the card (C). From 0801fb980beed7572e21fe9b921a21f4e524668e Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Thu, 29 Oct 2026 09:46:16 +0000 Subject: [PATCH 32/42] feat(profile): box pattern for public group profile, tunable spacing and radii --- .../Screens/Profile/ChatProfileScreen.swift | 16 ++- .../Screens/Profile/PublicGroupCards.swift | 34 ++--- .../Screens/Profile/PublicGroupLook.swift | 37 ++++++ .../Profile/PublicGroupProfileView.swift | 119 +++++++++++------- 4 files changed, 140 insertions(+), 66 deletions(-) diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index d38a7dd85..1a1de0ca0 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -375,10 +375,10 @@ struct ChatProfileScreen: View { .accessibilityInputLabels(["Share"]) .accessibilityIdentifier("chat-profile-share") }, + hasFooter: isMember, footer: { if isMember { - leaveButton - .padding(.horizontal, PublicGroupProfileView.inset) + publicLeaveButton } }, pinned: { @@ -392,6 +392,18 @@ struct ChatProfileScreen: View { } } + /// Leave Chat as the last box of a public group's stack. + private var publicLeaveButton: some View { + Button { + dialogItem = leaveDialog() + } label: { + ButtonStateLabel("Leave Chat", state: isLeaving ? .loading : .normal) + } + .buttonStyle(PublicGroupDestructiveBoxButtonStyle()) + .disabled(isLeaving) + .accessibilityIdentifier("chat-profile-leave") + } + private func publicContent(cover: UIImage?) -> PublicGroupProfileContent { let named = requirements.flatMap { requirementsNamed ? $0 : nil } return PublicGroupProfileContent( diff --git a/Flipcash/Core/Screens/Profile/PublicGroupCards.swift b/Flipcash/Core/Screens/Profile/PublicGroupCards.swift index 3a9cddb8f..7f9176cc9 100644 --- a/Flipcash/Core/Screens/Profile/PublicGroupCards.swift +++ b/Flipcash/Core/Screens/Profile/PublicGroupCards.swift @@ -19,6 +19,13 @@ struct PublicGroupRequirementsView: View { @Environment(\.bleedPalette) private var palette var body: some View { + content + .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) @@ -28,22 +35,22 @@ struct PublicGroupRequirementsView: View { footer(yourBalance, style: palette.controlSecondaryStyle) } } - .padding(Self.padding) + .padding(PublicGroupLook.cardPadding) .frame(maxWidth: .infinity, alignment: .leading) - .background(palette.controlTint, in: surface) + .background(palette.controlTint, in: PublicGroupLook.cardShape) } else { VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top, spacing: 8) { + HStack(alignment: .top, spacing: PublicGroupLook.cardGap) { ForEach(tiles) { tile in tileContent(tile, isWide: false) - .padding(Self.padding) + .padding(PublicGroupLook.cardPadding) .frame(maxWidth: .infinity, alignment: .leading) - .background(palette.controlTint, in: surface) + .background(palette.controlTint, in: PublicGroupLook.cardShape) } } if let yourBalance { footer(yourBalance, style: palette.secondaryStyle) - .padding(.horizontal, Self.padding) + .padding(.horizontal, PublicGroupLook.cardPadding) } } } @@ -126,11 +133,6 @@ struct PublicGroupRequirementsView: View { .accessibilityIdentifier("group-profile-your-balance") } - private var surface: RoundedRectangle { - RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous) - } - - private static var padding: CGFloat { 16 } } /// Who has been chatting in a public group lately: a facepile, how many people that is, when the @@ -170,12 +172,13 @@ struct PublicGroupPulseCard: View { hostRow(host) } } - .padding(.top, Self.padding) - .padding(.horizontal, Self.padding) - .padding(.bottom, host == nil ? Self.padding : 0) + .padding(.top, PublicGroupLook.cardPadding) + .padding(.horizontal, PublicGroupLook.cardPadding) + .padding(.bottom, host == nil ? PublicGroupLook.cardPadding : 0) .frame(maxWidth: .infinity, alignment: .leading) - .background(palette.controlTint, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + .background(palette.controlTint, in: PublicGroupLook.cardShape) .accessibilityElement(children: .contain) + .accessibilityLabel("Chatting recently") .accessibilityIdentifier("group-profile-chatting") } @@ -268,7 +271,6 @@ struct PublicGroupPulseCard: View { .accessibilityHidden(true) } - private static var padding: CGFloat { 16 } private static var faceSize: CGFloat { 36 } private static var faceOverlap: CGFloat { 10 } private static var hitHeight: CGFloat { 44 } diff --git a/Flipcash/Core/Screens/Profile/PublicGroupLook.swift b/Flipcash/Core/Screens/Profile/PublicGroupLook.swift index 6721678da..a6e73a613 100644 --- a/Flipcash/Core/Screens/Profile/PublicGroupLook.swift +++ b/Flipcash/Core/Screens/Profile/PublicGroupLook.swift @@ -25,6 +25,43 @@ enum PublicGroupLook { /// The cover's height from the top of the screen, under the status and navigation bars. static var coverHeight: CGFloat { Tuner.shared.value("Profile/Public/Cover height", default: CGFloat(274), in: 160...420, step: 1, unit: .points) } // LOCAL-TUNER + // MARK: Boxes and spacing + + /// The corner radius of every box: the requirement tiles, the pulse card and Leave Chat. + static var cardRadius: CGFloat { Tuner.shared.value("Profile/Public/Card radius", default: Metrics.boxRadius, in: 6...32, step: 1, unit: .points) } // LOCAL-TUNER + + /// The padding inside every box. + static var cardPadding: CGFloat { Tuner.shared.value("Profile/Public/Card padding", default: CGFloat(16), in: 8...28, step: 1, unit: .points) } // LOCAL-TUNER + + /// The gap between boxes, stacked and side by side. + static var cardGap: CGFloat { Tuner.shared.value("Profile/Public/Card gap", default: CGFloat(12), in: 0...32, step: 1, unit: .points) } // LOCAL-TUNER + + /// The gap from the last element of the identity to the first box. + static var identityToCards: CGFloat { Tuner.shared.value("Profile/Public/Identity to cards", default: CGFloat(40), in: 0...56, step: 1, unit: .points) } // LOCAL-TUNER + + /// The margin the page's content sits inside, on each side. + static var sideMargin: CGFloat { Tuner.shared.value("Profile/Public/Side margin", default: CGFloat(20), in: 12...32, step: 1, unit: .points) } // LOCAL-TUNER + + /// The gap from the avatar to the name. + static var avatarToName: CGFloat { Tuner.shared.value("Profile/Public/Avatar to name", default: CGFloat(12), in: 0...32, step: 1, unit: .points) } // LOCAL-TUNER + + /// The gap from the name to the member count. + static var nameToMeta: CGFloat { Tuner.shared.value("Profile/Public/Name to meta", default: CGFloat(4), in: 0...16, step: 1, unit: .points) } // LOCAL-TUNER + + /// The gap from the member count to the bio. + static var metaToBio: CGFloat { Tuner.shared.value("Profile/Public/Meta to bio", default: CGFloat(8), in: 0...24, step: 1, unit: .points) } // LOCAL-TUNER + + /// The gap from the last text of the identity to the Share and Edit pills. + static var bioToActions: CGFloat { Tuner.shared.value("Profile/Public/Bio to actions", default: CGFloat(16), in: 0...32, step: 1, unit: .points) } // LOCAL-TUNER + + /// The corner radius of the Share and Edit pills; half their height is a capsule. + static var pillRadius: CGFloat { Tuner.shared.value("Profile/Public/Pill radius", default: CGFloat(22), in: 6...22, step: 1, unit: .points) } // LOCAL-TUNER + + /// 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) diff --git a/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift b/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift index d9cdc4b59..8ec46a92b 100644 --- a/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift +++ b/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift @@ -35,7 +35,8 @@ struct PublicGroupProfileContent { } /// A public group's profile: its cover melting into a page of the cover's own color, the group's -/// identity and actions, what it takes to join and chat, and who has been chatting. +/// 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. @@ -56,7 +57,9 @@ struct PublicGroupProfileView Accessory /// The outline pills under the bio. @ViewBuilder let actions: () -> Actions - /// Ends the scroll, such as Leave Chat. + /// Whether ``footer`` has anything in it, which is when its box and the gap above it belong. + let hasFooter: Bool + /// The last box of the stack, such as Leave Chat. @ViewBuilder let footer: () -> Footer /// The bar pinned under the scroll. @ViewBuilder let pinned: () -> Pinned @@ -72,31 +75,34 @@ struct PublicGroupProfileView some View) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text(title) - .font(.appTextSmall) - .foregroundStyle(palette.secondaryStyle) - .accessibilityAddTraits(.isHeader) - card() - } - .padding(.horizontal, Self.inset) - .padding(.top, 16) - } - - /// The side inset the page sits on. - static var inset: CGFloat { 20 } private static var avatarSize: CGFloat { 72 } private static var avatarRing: CGFloat { 3 } } @@ -260,9 +256,12 @@ private struct PublicGroupPress: ViewModifier { private static let scale: CGFloat = 0.96 } -/// The outline pill under the bio: a capsule stroked in the palette's secondary color. +/// 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 { @@ -271,8 +270,8 @@ struct PublicGroupOutlineButtonStyle: ButtonStyle { .foregroundStyle(palette.secondaryStyle) .padding(.horizontal, 24) .frame(minHeight: Metrics.buttonHeightThin) - .overlay { Capsule().strokeBorder(palette.secondaryStyle, lineWidth: 1.5) } - .contentShape(Capsule()) + .overlay { Self.shape.strokeBorder(palette.secondaryStyle, lineWidth: 1.5) } + .contentShape(Self.shape) .modifier(PublicGroupPress(isPressed: configuration.isPressed)) } } @@ -300,6 +299,26 @@ struct PublicGroupCapsuleButtonStyle: ButtonStyle { static var sideInset: CGFloat { 32 } } +/// A box in the stack that is one destructive action, such as Leave Chat: a full-width row on the +/// cards' surface, radius and padding, at least 44pt tall, with the whole box as the tap target. +struct PublicGroupDestructiveBoxButtonStyle: ButtonStyle { + + @Environment(\.bleedPalette) private var palette + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.appTextMedium) + .foregroundStyle(Color.textError) + .tint(Color.textError) + .frame(maxWidth: .infinity) + .padding(PublicGroupLook.cardPadding) + .frame(minHeight: Metrics.buttonHeightThin) + .background(palette.controlTint, in: PublicGroupLook.cardShape) + .contentShape(PublicGroupLook.cardShape) + .modifier(PublicGroupPress(isPressed: configuration.isPressed)) + } +} + /// The press of a tappable row or face with no chrome of its own. struct PublicGroupDimButtonStyle: ButtonStyle { @@ -383,7 +402,11 @@ private struct PublicGroupProfilePreview: View { Button("Share") {} .buttonStyle(PublicGroupOutlineButtonStyle()) }, - footer: { EmptyView() }, + hasFooter: true, + footer: { + Button("Leave Chat") {} + .buttonStyle(PublicGroupDestructiveBoxButtonStyle()) + }, pinned: { Button("Open Chat") {} .buttonStyle(PublicGroupCapsuleButtonStyle()) From 35bfe993bb29f81bba42b63adbad783cacd2a706 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Thu, 29 Oct 2026 10:09:14 +0000 Subject: [PATCH 33/42] fix(profile): public group tuner scope, cover stretch, pulse card spacing --- Flipcash/Core/FlipcashApp.swift | 2 +- .../Screens/Profile/PublicGroupCards.swift | 18 +++++++++--------- .../Core/Screens/Profile/PublicGroupLook.swift | 9 +++++++++ .../Profile/PublicGroupProfileView.swift | 11 ++++++++++- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/Flipcash/Core/FlipcashApp.swift b/Flipcash/Core/FlipcashApp.swift index 1f728868b..3490f8164 100644 --- a/Flipcash/Core/FlipcashApp.swift +++ b/Flipcash/Core/FlipcashApp.swift @@ -32,7 +32,7 @@ struct FlipcashApp: App { } else if let benchmark = ScrollBenchmark.requested { benchmark } else { - mainScene.tunerFloating(groups: ["Chat list", "Chat list Row", "Chat list Title", "Chat list Preview", "Chat list Date", "Chat list Unread", "Chat v2 Date", "Tray Layout", "Tray Type", "Tray Destructive", "Tray Rows", "Tray Amount", "Tray Mute", "Tray E2EE", "Tray Fill", "Tray Motion", "Tray Label", "Tray Behavior", "Amount entry"]) // LOCAL-TUNER + mainScene.tunerFloating(groups: ["Profile"]) // LOCAL-TUNER } #else mainScene diff --git a/Flipcash/Core/Screens/Profile/PublicGroupCards.swift b/Flipcash/Core/Screens/Profile/PublicGroupCards.swift index 7f9176cc9..ed345217c 100644 --- a/Flipcash/Core/Screens/Profile/PublicGroupCards.swift +++ b/Flipcash/Core/Screens/Profile/PublicGroupCards.swift @@ -31,7 +31,7 @@ struct PublicGroupRequirementsView: View { tileContent(tile, isWide: true) if let yourBalance { PublicGroupHairline() - .padding(.vertical, 8) + .padding(.vertical, PublicGroupLook.dividerGap) footer(yourBalance, style: palette.controlSecondaryStyle) } } @@ -152,7 +152,7 @@ struct PublicGroupPulseCard: View { VStack(alignment: .leading, spacing: 0) { if !pulse.faces.isEmpty { facepile - .padding(.bottom, 12) + .padding(.bottom, PublicGroupLook.facepileToHeadline) } Text(PublicGroupCopy.chatting(count: pulse.chattingCount)) @@ -163,18 +163,16 @@ struct PublicGroupPulseCard: View { Text(meta) .font(.appTextCaption) .foregroundStyle(palette.controlSecondaryStyle) - .padding(.top, 2) + .padding(.top, PublicGroupLook.headlineToMeta) } if let host { PublicGroupHairline() - .padding(.vertical, 12) + .padding(.vertical, PublicGroupLook.dividerGap) hostRow(host) } } - .padding(.top, PublicGroupLook.cardPadding) - .padding(.horizontal, PublicGroupLook.cardPadding) - .padding(.bottom, host == nil ? PublicGroupLook.cardPadding : 0) + .padding(PublicGroupLook.cardPadding) .frame(maxWidth: .infinity, alignment: .leading) .background(palette.controlTint, in: PublicGroupLook.cardShape) .accessibilityElement(children: .contain) @@ -227,7 +225,7 @@ struct PublicGroupPulseCard: View { Button { onSelect(host.userID) } label: { - HStack(spacing: 12) { + HStack(alignment: .center, spacing: 12) { ContactAvatarView( id: host.userID.uuidString, displayName: host.name, @@ -252,8 +250,10 @@ struct PublicGroupPulseCard: View { .iconSize(16) .foregroundStyle(palette.controlSecondaryStyle) } - .frame(minHeight: 44) + // 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)") diff --git a/Flipcash/Core/Screens/Profile/PublicGroupLook.swift b/Flipcash/Core/Screens/Profile/PublicGroupLook.swift index a6e73a613..f0491b2e4 100644 --- a/Flipcash/Core/Screens/Profile/PublicGroupLook.swift +++ b/Flipcash/Core/Screens/Profile/PublicGroupLook.swift @@ -33,6 +33,15 @@ enum PublicGroupLook { /// The padding inside every box. static var cardPadding: CGFloat { Tuner.shared.value("Profile/Public/Card padding", default: CGFloat(16), in: 8...28, step: 1, unit: .points) } // LOCAL-TUNER + /// The space above and below the hairline in a box, between its summary and the row under it. + static var dividerGap: CGFloat { Tuner.shared.value("Profile/Public/Divider gap", default: CGFloat(12), in: 4...24, step: 1, unit: .points) } // LOCAL-TUNER + + /// The gap from the pulse card's facepile to its headline. + static var facepileToHeadline: CGFloat { Tuner.shared.value("Profile/Public/Facepile to headline", default: CGFloat(12), in: 4...24, step: 1, unit: .points) } // LOCAL-TUNER + + /// The gap from the pulse card's headline to its meta line. + static var headlineToMeta: CGFloat { Tuner.shared.value("Profile/Public/Headline to meta", default: CGFloat(2), in: 0...12, step: 1, unit: .points) } // LOCAL-TUNER + /// The gap between boxes, stacked and side by side. static var cardGap: CGFloat { Tuner.shared.value("Profile/Public/Card gap", default: CGFloat(12), in: 0...32, step: 1, unit: .points) } // LOCAL-TUNER diff --git a/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift b/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift index 8ec46a92b..044bd0a62 100644 --- a/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift +++ b/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift @@ -140,7 +140,16 @@ struct PublicGroupProfileView Date: Thu, 29 Oct 2026 10:16:21 +0000 Subject: [PATCH 34/42] fix(profile): bake tuned cover height 320, fade 214, card radius 24 --- Flipcash/Core/Screens/Profile/PublicGroupLook.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Flipcash/Core/Screens/Profile/PublicGroupLook.swift b/Flipcash/Core/Screens/Profile/PublicGroupLook.swift index f0491b2e4..52a26d147 100644 --- a/Flipcash/Core/Screens/Profile/PublicGroupLook.swift +++ b/Flipcash/Core/Screens/Profile/PublicGroupLook.swift @@ -20,15 +20,15 @@ enum PublicGroupLook { static var lightnessPull: Double { Tuner.shared.value("Profile/Public/Pull", default: 0.6, in: 0...1, step: 0.01) } // LOCAL-TUNER /// The length of the melt from the cover into the page. - static var fadeHeight: CGFloat { Tuner.shared.value("Profile/Public/Fade height", default: CGFloat(183), in: 0...320, step: 1, unit: .points) } // LOCAL-TUNER + static var fadeHeight: CGFloat { Tuner.shared.value("Profile/Public/Fade height", default: CGFloat(214), in: 0...320, step: 1, unit: .points) } // LOCAL-TUNER /// The cover's height from the top of the screen, under the status and navigation bars. - static var coverHeight: CGFloat { Tuner.shared.value("Profile/Public/Cover height", default: CGFloat(274), in: 160...420, step: 1, unit: .points) } // LOCAL-TUNER + static var coverHeight: CGFloat { Tuner.shared.value("Profile/Public/Cover height", default: CGFloat(320), in: 160...420, step: 1, unit: .points) } // LOCAL-TUNER // MARK: Boxes and spacing /// The corner radius of every box: the requirement tiles, the pulse card and Leave Chat. - static var cardRadius: CGFloat { Tuner.shared.value("Profile/Public/Card radius", default: Metrics.boxRadius, in: 6...32, step: 1, unit: .points) } // LOCAL-TUNER + static var cardRadius: CGFloat { Tuner.shared.value("Profile/Public/Card radius", default: CGFloat(24), in: 6...32, step: 1, unit: .points) } // LOCAL-TUNER /// The padding inside every box. static var cardPadding: CGFloat { Tuner.shared.value("Profile/Public/Card padding", default: CGFloat(16), in: 8...28, step: 1, unit: .points) } // LOCAL-TUNER From ea86bdc5d8356c646f792ddaab57ddafa316fb07 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Thu, 29 Oct 2026 10:21:08 +0000 Subject: [PATCH 35/42] feat(profile): tunable content offset and avatar ring on the public group profile --- Flipcash/Core/Screens/Profile/PublicGroupLook.swift | 10 ++++++++++ .../Core/Screens/Profile/PublicGroupProfileView.swift | 11 ++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/Flipcash/Core/Screens/Profile/PublicGroupLook.swift b/Flipcash/Core/Screens/Profile/PublicGroupLook.swift index 52a26d147..705f637ac 100644 --- a/Flipcash/Core/Screens/Profile/PublicGroupLook.swift +++ b/Flipcash/Core/Screens/Profile/PublicGroupLook.swift @@ -25,6 +25,16 @@ enum PublicGroupLook { /// The cover's height from the top of the screen, under the status and navigation bars. static var coverHeight: CGFloat { Tuner.shared.value("Profile/Public/Cover height", default: CGFloat(320), in: 160...420, step: 1, unit: .points) } // LOCAL-TUNER + /// 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 var contentOffset: CGFloat { Tuner.shared.value("Profile/Public/Content offset", default: CGFloat(4), in: -40...160, step: 1, unit: .points) } // LOCAL-TUNER + + /// Whether the identity's avatar is drawn inside a ring of the page's color. + static var avatarStroke: Bool { Tuner.shared.value("Profile/Public/Avatar stroke", default: true) } // LOCAL-TUNER + + /// The width of that ring. + static var avatarStrokeWidth: CGFloat { Tuner.shared.value("Profile/Public/Avatar stroke width", default: CGFloat(3), in: 0...8, step: 0.5, unit: .points) } // LOCAL-TUNER + // MARK: Boxes and spacing /// The corner radius of every box: the requirement tiles, the pulse card and Leave Chat. diff --git a/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift b/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift index 044bd0a62..5f13d699b 100644 --- a/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift +++ b/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift @@ -104,7 +104,9 @@ struct PublicGroupProfileView Date: Thu, 29 Oct 2026 10:22:21 +0000 Subject: [PATCH 36/42] feat(profile): Leave Chat as a calm capsule under the public group's boxes --- .../Screens/Profile/ChatProfileScreen.swift | 4 +- .../Screens/Profile/PublicGroupLook.swift | 8 +- .../Profile/PublicGroupProfileView.swift | 73 +++++++++++-------- 3 files changed, 52 insertions(+), 33 deletions(-) diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 1a1de0ca0..44b3660e1 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -392,14 +392,14 @@ struct ChatProfileScreen: View { } } - /// Leave Chat as the last box of a public group's stack. + /// Leave Chat, closing a public group's stack. private var publicLeaveButton: some View { Button { dialogItem = leaveDialog() } label: { ButtonStateLabel("Leave Chat", state: isLeaving ? .loading : .normal) } - .buttonStyle(PublicGroupDestructiveBoxButtonStyle()) + .buttonStyle(PublicGroupClosingButtonStyle()) .disabled(isLeaving) .accessibilityIdentifier("chat-profile-leave") } diff --git a/Flipcash/Core/Screens/Profile/PublicGroupLook.swift b/Flipcash/Core/Screens/Profile/PublicGroupLook.swift index 705f637ac..e558647ef 100644 --- a/Flipcash/Core/Screens/Profile/PublicGroupLook.swift +++ b/Flipcash/Core/Screens/Profile/PublicGroupLook.swift @@ -37,7 +37,7 @@ enum PublicGroupLook { // MARK: Boxes and spacing - /// The corner radius of every box: the requirement tiles, the pulse card and Leave Chat. + /// The corner radius of every box: the requirement tiles and the pulse card. static var cardRadius: CGFloat { Tuner.shared.value("Profile/Public/Card radius", default: CGFloat(24), in: 6...32, step: 1, unit: .points) } // LOCAL-TUNER /// The padding inside every box. @@ -55,6 +55,12 @@ enum PublicGroupLook { /// The gap between boxes, stacked and side by side. static var cardGap: CGFloat { Tuner.shared.value("Profile/Public/Card gap", default: CGFloat(12), in: 0...32, step: 1, unit: .points) } // LOCAL-TUNER + /// The gap above the closing action under the boxes, such as Leave Chat. + static var leaveGap: CGFloat { Tuner.shared.value("Profile/Public/Leave gap", default: CGFloat(12), in: 0...48, step: 1, unit: .points) } // LOCAL-TUNER + + /// Whether Leave Chat's label is the white control label rather than the quieter secondary tint. + static var leaveLabelIsWhite: Bool { Tuner.shared.value("Profile/Public/Leave label white", default: false) } // LOCAL-TUNER + /// The gap from the last element of the identity to the first box. static var identityToCards: CGFloat { Tuner.shared.value("Profile/Public/Identity to cards", default: CGFloat(40), in: 0...56, step: 1, unit: .points) } // LOCAL-TUNER diff --git a/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift b/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift index 5f13d699b..32459758f 100644 --- a/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift +++ b/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift @@ -59,7 +59,7 @@ struct PublicGroupProfileView Actions /// Whether ``footer`` has anything in it, which is when its box and the gap above it belong. let hasFooter: Bool - /// The last box of the stack, such as Leave Chat. + /// 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 @@ -76,28 +76,33 @@ struct PublicGroupProfileView some View { configuration.label .font(.appTextMedium) - .foregroundStyle(Color.textError) - .tint(Color.textError) + .foregroundStyle(labelStyle) + .tint(labelStyle) .frame(maxWidth: .infinity) - .padding(PublicGroupLook.cardPadding) - .frame(minHeight: Metrics.buttonHeightThin) - .background(palette.controlTint, in: PublicGroupLook.cardShape) - .contentShape(PublicGroupLook.cardShape) + .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. @@ -419,7 +432,7 @@ private struct PublicGroupProfilePreview: View { hasFooter: true, footer: { Button("Leave Chat") {} - .buttonStyle(PublicGroupDestructiveBoxButtonStyle()) + .buttonStyle(PublicGroupClosingButtonStyle()) }, pinned: { Button("Open Chat") {} From 99c4a4f94712c737f600354e4f1334a19003d0b7 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Thu, 29 Oct 2026 10:22:38 +0000 Subject: [PATCH 37/42] fix(ui): app background #121212 --- .../Colors/background.colorset/Contents.json | 6 +++--- FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift | 2 +- NotificationContent/NotificationViewController.swift | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) 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/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift index fb9644659..88bd94d14 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift @@ -35,7 +35,7 @@ extension ShapeStyle where Self == Color { public static var bannerInfo: Color { Color("backgroundSecondary") } public static var bannerSuccess: Color { Color("backgroundSecondary") } - public static var backgroundMain: Color { Tuner.value("App/Background", default: Color(r: 0, g: 0, b: 0)) } // LOCAL-TUNER + public static var backgroundMain: Color { Tuner.value("App/Background", default: Color(r: 18, g: 18, b: 18)) } // LOCAL-TUNER public static var backgroundSecondary: Color { Color("backgroundSecondary") } public static var backgroundRow: Color { Color.white.opacity(0.05) } public static var rowSeparator: Color { Color.white.opacity(0.1) } diff --git a/NotificationContent/NotificationViewController.swift b/NotificationContent/NotificationViewController.swift index 1da71a346..4fa0bed86 100644 --- a/NotificationContent/NotificationViewController.swift +++ b/NotificationContent/NotificationViewController.swift @@ -24,10 +24,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 From 6bf7f70c7b6442b3ba12cbe04eb66a67bc0f617c Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Thu, 29 Oct 2026 10:36:51 +0000 Subject: [PATCH 38/42] fix(profile): app back arrow on the public group profile --- .../Profile/PublicGroupProfileView.swift | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift b/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift index 32459758f..62df0e298 100644 --- a/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift +++ b/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift @@ -4,6 +4,7 @@ // import SwiftUI +import UIKit import FlipcashCore import FlipcashUI @@ -128,6 +129,8 @@ struct PublicGroupProfileView 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 + } + } +} From 0ab4b2cfea1656f33692a338b93f9f6696136e24 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Thu, 29 Oct 2026 10:36:51 +0000 Subject: [PATCH 39/42] chore(profile): public group profile playground in Beta Features (local tuner) --- .../PublicGroupProfilePlayground.swift | 308 ++++++++++++++++++ .../SettingsAdvancedBetaFeaturesScreen.swift | 1 + 2 files changed, 309 insertions(+) create mode 100644 Flipcash/Core/Screens/Profile/PublicGroupProfilePlayground.swift diff --git a/Flipcash/Core/Screens/Profile/PublicGroupProfilePlayground.swift b/Flipcash/Core/Screens/Profile/PublicGroupProfilePlayground.swift new file mode 100644 index 000000000..680f7629e --- /dev/null +++ b/Flipcash/Core/Screens/Profile/PublicGroupProfilePlayground.swift @@ -0,0 +1,308 @@ +// +// PublicGroupProfilePlayground.swift +// Flipcash +// + +#if DEBUG // LOCAL-TUNER: the public group profile playground, a local feel tool +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The Beta Features entry that opens the public group profile playground. +struct PublicGroupProfilePlaygroundEntry: View { + + var body: some View { + VStack(spacing: 0) { + SettingsSectionHeader("Profile Playground") + NavigationLink { + PublicGroupProfilePlayground() + } label: { + PlaygroundRow(title: "Public group profile playground") + } + .buttonStyle(.plain) + .accessibilityIdentifier("public-group-profile-playground") + } + } +} + +/// Every state of the public group profile, from sample data with no network. Each one pushes the +/// full screen, whose ⋯ menu swaps the state in place so the tuner panel can stay open. No control +/// on the profile does anything. +struct PublicGroupProfilePlayground: View { + + var body: some View { + Background(color: .backgroundMain) { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(PublicGroupProfilePlaygroundState.allCases) { state in + NavigationLink { + PublicGroupProfilePlaygroundPage(state: state) + } label: { + PlaygroundRow(title: state.title) + } + .buttonStyle(.plain) + .accessibilityIdentifier("profile-playground-\(state.rawValue)") + } + } + } + } + .navigationTitle("Profile Playground") + .toolbarTitleDisplayMode(.inline) + } +} + +/// One state of the profile, drawn full screen. +struct PublicGroupProfilePlaygroundPage: View { + + @State var state: PublicGroupProfilePlaygroundState + + var body: some View { + let sample = state.sample + PublicGroupProfileView( + content: sample.content, + hasPinned: true, + portraitData: { _ in nil }, + loadPortrait: { _ in }, + onSelectPerson: { _ in }, + onToken: { _ in }, + accessory: { EmptyView() }, + actions: { + if sample.isHost { + Button("Edit Group") {} + .buttonStyle(PublicGroupOutlineButtonStyle()) + } + Button("Share") {} + .buttonStyle(PublicGroupOutlineButtonStyle()) + }, + hasFooter: sample.isMember, + footer: { + if sample.isMember { + Button {} label: { + ButtonStateLabel("Leave Chat", state: .normal) + } + .buttonStyle(PublicGroupClosingButtonStyle()) + } + }, + pinned: { + Button("Open Chat") {} + .buttonStyle(PublicGroupCapsuleButtonStyle()) + .padding(.horizontal, PublicGroupCapsuleButtonStyle.sideInset) + .padding(.top, 12) + .padding(.bottom, 8) + } + ) + .bleedPalette(key: sample.bleedKey, image: sample.bleedImage, settings: PublicGroupLook.settings) + // A fresh page per state, so the palette the last one held doesn't color one with no image. + .id(state) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Picker("State", selection: $state) { + ForEach(PublicGroupProfilePlaygroundState.allCases) { state in + Text(state.title).tag(state) + } + } + } label: { + Image.system(.ellipsis) + } + .accessibilityIdentifier("profile-playground-states") + } + } + } +} + +/// A plain Beta Features row: a title and a chevron over a hairline. +private struct PlaygroundRow: View { + + let title: String + + var body: some View { + HStack { + Text(title) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .padding(.vertical, 10) + Spacer() + Image.icon(.chevronRightMedium) + .iconSize(16) + .foregroundStyle(Color.textSecondary) + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .vSeparator(color: .rowSeparator, position: .bottom) + .contentShape(Rectangle()) + } +} + +// MARK: - States - + +enum PublicGroupProfilePlaygroundState: String, CaseIterable, Identifiable, Hashable { + case standard + case busy + case quiet + case nobody + case split + case noCover + case noPicture + case member + case nonMember + case host + case longTitle + case loading + + var id: String { rawValue } + + var title: String { + switch self { + case .standard: "Default: cover, 6 chatting" + case .busy: "Busy: 84, +79, long bio" + case .quiet: "Quiet: 1 chatting" + case .nobody: "Nobody this week" + case .split: "Join ≠ chat (split tiles)" + case .noCover: "No cover (avatar colour)" + case .noPicture: "No avatar or picture" + case .member: "Member (Leave)" + case .nonMember: "Non-member (no Leave)" + case .host: "Host (Edit Group)" + case .longTitle: "Long title" + case .loading: "Requirements loading" + } + } + + /// The sample this state draws. + @MainActor + var sample: PublicGroupProfilePlaygroundSample { + var sample = PublicGroupProfilePlaygroundSample(id: rawValue) + switch self { + case .standard, .member: + break + case .busy: + sample.cover = .warm + sample.chatting = 84 + sample.memberCount = 1_284 + sample.description = Self.longBio + case .quiet: + sample.cover = .navy + sample.chatting = 1 + sample.memberCount = 3 + case .nobody: + sample.cover = .green + sample.chatting = 0 + case .split: + sample.split = true + case .noCover: + sample.cover = nil + sample.avatar = .warm + case .noPicture: + sample.cover = nil + sample.avatar = nil + sample.avatarBlurhash = nil + case .nonMember: + sample.isMember = false + case .host: + sample.isHost = true + case .longTitle: + sample.title = "The Extremely Long Named Flipcash Product Feedback and Bug Reports Group" + case .loading: + sample.isLoading = true + } + return sample + } + + /// 160 characters. + private static let longBio = "Weekly drops, honest feedback and the odd meme. Post bugs, ideas and wins here; the team reads everything and replies fast. Be kind, stay on topic and have fun." +} + +/// What one playground state draws, built up from the default. +struct PublicGroupProfilePlaygroundSample { + + /// A generated image the page can be colored from. + enum Swatch: String { + case blurhash, navy, warm, green + + @MainActor + var image: UIImage? { + switch self { + case .blurhash: BlurHashCache.shared.image(for: PublicGroupProfilePlaygroundSample.blurhash, width: 32, height: 32) + case .navy: Self.navyImage + case .warm: Self.warmImage + case .green: Self.greenImage + } + } + + private static let navyImage = gradient(UIColor(red: 0.08, green: 0.12, blue: 0.36, alpha: 1), UIColor(red: 0.20, green: 0.32, blue: 0.70, alpha: 1)) + private static let warmImage = gradient(UIColor(red: 0.85, green: 0.38, blue: 0.16, alpha: 1), UIColor(red: 0.95, green: 0.72, blue: 0.30, alpha: 1)) + private static let greenImage = gradient(UIColor(red: 0.06, green: 0.36, blue: 0.22, alpha: 1), UIColor(red: 0.42, green: 0.74, blue: 0.40, alpha: 1)) + + private static func gradient(_ from: UIColor, _ to: UIColor) -> UIImage { + let size = CGSize(width: 64, height: 64) + return UIGraphicsImageRenderer(size: size).image { context in + let colors = [from.cgColor, to.cgColor] as CFArray + guard let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: colors, locations: [0, 1]) else { return } + context.cgContext.drawLinearGradient(gradient, start: .zero, end: CGPoint(x: size.width, y: size.height), options: []) + } + } + } + + static let blurhash = "LGF5]+Yk^6#M@-5c,1J5@[or[Q6." + + let id: String + var title = "Flipcash Product Feedback" + var memberCount: UInt64 = 22 + var description = "Something look off? Not work? Crash? Post it here for the team to be aware." + var cover: Swatch? = .blurhash + var avatar: Swatch? + var avatarBlurhash: String? = Self.blurhash + var chatting = 6 + var split = false + var isLoading = false + var isMember = true + var isHost = false + + /// The image the page is colored from: the cover, else the avatar, else none. + @MainActor + var bleedImage: UIImage? { (cover ?? avatar)?.image } + + /// Identifies ``bleedImage`` for the color cache. + var bleedKey: String? { (cover ?? avatar).map { "playground-\($0.rawValue)" } } + + @MainActor + var content: 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 count = max(chatting, 1) + let chatters = (0.. Date: Thu, 29 Oct 2026 11:35:10 +0000 Subject: [PATCH 40/42] chore(design): bake tuned values and remove the local tuner Every LOCAL-TUNER value is baked to its default in the call site's original form; tuner wiring, floating panels and the profile playground entry are gone. FlipcashUI is back on the iOS 18 floor with no TunerKit dependency. --- .claude/plans/2026-10-29-profile-pages.md | 504 ------------------ Flipcash/Core/FlipcashApp.swift | 110 ++-- .../Screens/Conversation/AttachMenu.swift | 7 +- .../Screens/Conversation/AttachSurface.swift | 4 +- .../Conversation/ConversationBottomBar.swift | 62 +-- .../Conversation/ConversationScreen.swift | 44 +- Flipcash/Core/Screens/Main/Home/HomeTab.swift | 1 - .../Screens/Main/Home/TabBarBadgedIcon.swift | 8 +- .../Screens/Main/Home/TabBarItemStyle.swift | 48 +- .../Core/Screens/Main/Tips/RecipientRow.swift | 183 ++----- .../Main/Tips/TipConversationsScreen.swift | 76 ++- .../Screens/Profile/E2eeLearnMoreSheet.swift | 34 +- .../Core/Screens/Profile/MuteChatSheet.swift | 43 +- .../Screens/Profile/PublicGroupLook.swift | 48 +- .../PublicGroupProfilePlayground.swift | 308 ----------- .../SettingsAdvancedBetaFeaturesScreen.swift | 56 +- .../Screens/Settings/SwipeFlowShowcase.swift | 2 +- .../Core/Session/SessionAuthenticator.swift | 1 - Flipcash/UI/EnterAmountView.swift | 14 +- Flipcash/UI/View+Dialog.swift | 231 ++++---- FlipcashUI/Package.swift | 4 +- .../Chat/BubbleBackgroundView.swift | 6 +- .../Chat/ChatAuthorAvatarView.swift | 27 +- .../FlipcashUI/Chat/ChatBackdrop.swift | 13 +- .../FlipcashUI/Chat/ChatBubbleView.swift | 1 - .../FlipcashUI/Chat/ChatColumnCell.swift | 2 +- .../Chat/ChatDateSeparatorCell.swift | 34 +- .../FlipcashUI/Chat/ChatMotionSandbox.swift | 13 +- .../Chat/ChatScreenViewController.swift | 34 +- .../Sources/FlipcashUI/Chat/ChatStyle.swift | 288 +++------- .../FlipcashUI/Chat/ChatSwipeToReply.swift | 14 +- .../Chat/ChatTypingIndicatorCell.swift | 4 +- .../FlipcashUI/Chat/ChatViewController.swift | 28 - .../FlipcashUI/Chat/JumpToBottomModel.swift | 10 +- .../FlipcashUI/Chat/LinkableBubbleView.swift | 57 +- .../FlipcashUI/Chat/MessageBackdrop.swift | 20 +- .../FlipcashUI/Chat/ReactionPillView.swift | 2 +- .../Sources/FlipcashUI/Theme/Colors.swift | 2 +- .../FlipcashUI/Tray/TrayAmountMetrics.swift | 16 +- .../Sources/FlipcashUI/Tray/TrayMetrics.swift | 150 ++---- .../Sources/FlipcashUI/Tray/TrayMotion.swift | 56 +- .../FlipcashUI/Tray/TrayOptionRow.swift | 13 +- .../Sources/FlipcashUI/Tray/TrayPage.swift | 20 +- .../Sources/FlipcashUI/Tray/TraySandbox.swift | 17 +- .../Views/Contacts/ContactAvatarView.swift | 2 +- .../FlipcashUI/Views/Containers/ListRow.swift | 32 +- .../Views/Controls/SwipeShowcase.swift | 4 +- .../Sources/FlipcashUI/Views/KeyPadView.swift | 4 +- 48 files changed, 619 insertions(+), 2038 deletions(-) delete mode 100644 .claude/plans/2026-10-29-profile-pages.md delete mode 100644 Flipcash/Core/Screens/Profile/PublicGroupProfilePlayground.swift diff --git a/.claude/plans/2026-10-29-profile-pages.md b/.claude/plans/2026-10-29-profile-pages.md deleted file mode 100644 index 3d622e59d..000000000 --- a/.claude/plans/2026-10-29-profile-pages.md +++ /dev/null @@ -1,504 +0,0 @@ -# Profile pages redesign (User, Private Group, Public Group) - -Branch `feat/ui-profile` (local only), worktree `~/Developer/code-ios-app-profile`, cut from -`feat/ui-swipe-to-send` @ c228fb4a. Round 1 is **Figma only**, on the page **Profiles** (814:2) in -"Components – Flipcash" (`R2TbSk4RbeLZz29rJr61uE`). Aiden makes every taste call. - -Note for shipping: this plan lives under `.claude/`, which a design PR must not touch. Leave it out -of any PR, or ask the dev first. - -## 1. What exists today - -Two screens and one shared header: - -| Profile | Screen | Shared pieces | -|---|---|---| -| User | `Screens/Profile/UserProfileScreen.swift` (`UserProfileContent` :46, `UserProfileViewModel` :462) | `ProfileHeaderView`, `ProfileCoverBanner`, `ProfilePinnedBackdrop` | -| Private group + public group | `Screens/Profile/ChatProfileScreen.swift`, one screen that branches on `isPrivate` (:70) and `isMember` (:59) | same, plus `GroupChattingGrid` | - -The shared skeleton: -- A cover 214pt tall, made of literals. With no image it shows a flat grey `coverPlaceholder`, and that grey is what most groups show. -- An 84pt avatar overlapping the cover by 42pt, with a 5pt ring. -- A 38pt share circle. -- A title in `.appDisplaySmall`. -- The side inset is 24 (`ProfileHeaderMetrics.inset`). The house standard says 20. -- The CTA is `CodeButton(.filled)`: a square-ish white slab, not the 54pt capsule. - -The bottom slot: -- On group pages, "Leave Chat" (`.subtle`) often takes the CTA position. -- No profile has a `#Preview`, a loading skeleton or an error state. - -**The CTA slot carries every state.** Its logic is load-bearing and must not change: - -| Profile and state | CTA | Source | -|---|---|---| -| User, new | "Send £X to Start Chatting" → gate → `StartChattingSheet` (Lens swipe) | `ProfilePinnedAction`, `UserProfileScreen.swift:351` | -| User, fee unknown | "Start Chatting" → send amount | same | -| User, existing DM | "Open Chat" | same | -| User, blocked | "Unblock" | same | -| Public group | "Open Chat"; the join happens in the chat's gate, not on the profile | `ChatProfileScreen.swift:512` | -| Private, requester | "Request to Join", then "Requested Xm ago" (disabled) and "Cancel Request" | `:550-585` | -| Private, invite preview (Brandon, in development) | "Join Group" | Figma 12846:59 | -| Member, any group | "Leave Chat" (subtle) | `:682` | - -## 2. What the data really has - -Sources: flipcash2-client-protocol 0.20.0 (f0bc47ee), `FlipcashCore/Models/Profile.swift` and -`Conversation.swift`. - -### User (`profile.v1.UserProfile` → `Profile`) - -| Field | Status | -|---|---| -| Display name, username, avatar, cover, bio (≤160), joined date (`join_ts`) | exists and is shown | -| Minimum to chat | exists. It is shown via `TipFloor.toOpenDM(...).displayed`, not the raw field. | -| Favourite public groups | exists (`GetFeaturedGroups`) | -| Card colour (`tipCardCustomization.colorHex`) | exists, fetched, **not displayed** | -| X profile (`social_profiles`: handle, verified type, follower count) | **in the proto, not mapped in Swift**. Dev question. | -| Currencies they hold | **absent**. Holdings exist only on groups. Backend question. | -| Chat state with this user | derived locally (DM exists, blocked, muted) | - -### Groups (`chat.v1.Metadata` → `Conversation`) - -| Field | Private | Public | -|---|---|---| -| Title, picture, cover, description (≤160, not persisted, so nil after a cold start) | exists | exists | -| Member count (`roster_summary.member_count`) | shown | **exists, not shown** | -| Faces | roster (`GetRoster`, members only, paged by 50) | `SampleChatters` (≤100, creator first, then recent senders; not the full roster) | -| Host | `creator` (wave badge) | same | -| Roles beyond host | absent | absent | -| Join requirement | host approval through the lobby; no `rules` | `rules.listener` minimum balance (`amount`, `mints[≤1]`) | -| Minimum to chat | absent | `rules.speaker` (falls back to the join rule) | -| Holdings (`holdings_summary`: mint, quarks, holder_count, as_of) | absent | **exists, never displayed**. Only set above a server holder minimum. | -| Last activity, last message | exists, not shown | exists, not shown | -| Created date, category or tags, "full" | absent | absent | -| Muted | `viewer_state.mute` | same | - -## 3. Load-bearing walls (do not touch) - -- **Every CTA's action and its resolve order.** That covers `ProfilePinnedAction`, `tapPinned`, the give-cash gate, the `StartChattingSheet` hand-off and `PostPaymentChat`. -- **Fee and amount text.** It is rendered by `TipFloor` and `FiatAmount.formatted*`; we show it, we don't re-derive it. -- **Group logic:** the gate (`ConversationGate`), membership (`isMember` comes from the roster), `canEdit` as the sole gate on Edit, and the lobby request and cancel flow. -- **Navigation:** the `ChatProfileOrigin` and `UserProfileOrigin` semantics, `popToRoot` on leave, block popping, and the profile being a **pushed page** (not a tray). -- **Accessibility ids:** `profile-*`, `group-profile-*` and `chat-profile-*` stay. - -## 4. In-flight work the redesign must leave room for - -- **Group Invites** (Brandon, 12844:56): - - an invite-preview state ("You've been invited to join", Join Group, a 10-face grid with "+39 more"); - - a dead-link subtitle; - - an "Invite to …" sheet from Share; - - "Invite Links" in the ⋯ menu. -- **Moderation** (12419:77643): "Moderation ›" in the host's ⋯ menu, and an "In {group}" section (Silence, Remove) on a member's profile menu. -- **Join Requests on chats**: lives on the chat-list row, and the profile's Join Requests card stays. - -## 5. Prior art (short) - -The prior-art agent's full report has Mobbin links. Patterns worth keeping: -1. One skeleton with a swapped subtitle (Telegram). -2. The price in a single pinned capsule (Orb, Telegram, Revolut). -3. Metadata as one muted line (Threads, Luma, Partiful). -4. A colour wash taken from the cover or avatar instead of glass over content (Luma, Discord, Arc). -5. A facepile plus a count for groups (Instagram, Base). -6. Private vs public described by how you get in, not by a lock (Telegram, Discord). -7. Own vs other differs only in the action row (Cash App, Partiful). - -Avoid three-stat columns, upsell cards and glass on content. - -## 6. Directions (round 1: Figma, all three types side by side) - -All three share: -- The CTA is a 54pt capsule, `.filled`, the only filled button on the screen. -- Glass only on the back and ⋯ circles, on iOS 26+, with a material fallback on iOS 18. The CTA floats over - the shipped fade: no bar surface and no glass. -- 20pt margins (`screen/margin`). -- 2pt Central icons and rows v2. -- Presses are a small scale; state changes snap. - -### A · Editorial -- **Type-led and calm.** The cover becomes a soft wash: the cover image, or a tint taken from the avatar, fading to black about a third of the way down. No grey block. -- **Identity block:** a large left-aligned name with the avatar on the right (about 72pt), then one muted line ("@ted · Joined Feb 2026", "Private · 5 members · Host approves", "Public · 22 people · $100 to join"), then the bio or description. -- **Sections** are quiet rows v2 lists: Favourite groups, Members (facepile row → list), Requirements. - -References: Threads, Luma, Cash App. - -### B · Cover stage -- **An evolution of today's skeleton, made photographic.** A taller full-bleed cover that fades into black, with no hard edge. -- **Empty covers** get a gradient taken from the avatar instead of grey. -- **Identity is centred:** about a 96pt avatar overlapping the fade, then the name and handle. -- **One grouped info card per question:** - - User: "Minimum to chat · Joined". - - Group: "Access" (Private · host approves, or Public · Join $100 · Chat $100), then "Members" as a facepile and count. - -References: Orb, Venmo org, Revolut group, Discord. - -### C · The card -- **Identity lives on an object.** A rounded card at the top holds the cover, avatar and name, like the Flipcash tip card or a Family wallet card. The card colour can come from `tipCardCustomization.colorHex`, which is fetched but unused today. -- **Group cards** carry the facepile and access line on the card itself. -- **Below the card**, grouped rows v2 (style B) hold the rest. -- **The most ownable direction:** it ties the profile to "Share Profile → View Card". - -References: Family, Base token card, the Flipcash tip card. - -## 7. Taste calls for Aiden (A/B) - -1. Minimum to chat appears twice, on the stat card and in the CTA label. **A:** CTA only. **B:** keep both. -2. Leave Chat. **A:** at the end of the scroll as a quiet destructive row. **B:** in the ⋯ menu. B moves a control between view code and `ProfileMenuItems`, so ask the dev first. -3. Empty cover. **A:** a tint taken from the avatar. **B:** no cover area at all when there's no image. -4. Avatar. **A:** left-aligned. **B:** centred. - -## 8. Open questions for the dev or backend - -1. Currencies a user holds: not in `UserProfile`. Is a field planned, and is it public? -2. The X `social_profiles` in the proto: OK to map into `Profile` and show a verified handle? -3. Public groups: OK to show `member_count` (it exists) and `holdings_summary` ("Held by N people")? What are the semantics of the server holder minimum? -4. Show met or unmet requirement state on the public profile? That needs the `ConversationGate` comparison, so the dev owns it. -5. Should the public profile's CTA ever say "Join" rather than "Open Chat"? Today the join is in the chat by design (Oct 3 note: "The profile always opens the chat"). -6. Group created date: absent. Is it wanted? -7. Loading and error: there's no skeleton, and a failed fetch is silent. Is a design-only skeleton OK? -8. Own profile: your own handle routes to the tip card, not this screen. The Oct 3 design had "the You tab becomes your profile". Out of scope here? - -## 9. Round 1 frames (Figma) - -Page Profiles `814:2`. Sections and frames (User / Private / Public, iOS, Dark): - -| Direction | Section | Frames | -|---|---|---| -| A · Editorial | 818:2 | 822:2 · 822:162 · 822:360 | -| B · Cover stage | 818:5 | 822:3997 · 825:4344 · 828:935 | -| C · The card | 818:8 | 823:460 · 824:572 · 827:811 | - -The states drawn: -- User: a new contact, with "Send £1.51 to Start Chatting". -- Private: a member who arrived from a link, with Open Chat. -- Public: a non-member, with Open Chat. - -Photos are gradient stand-ins named `⚠︎ photo:`. New pieces are named `⚠︎ not in Screen Kit:` (header wash, cover fade, -identity block, facepile, stat card, profile card). - -**The critique (design-critic, fresh context)** returned "approve with changes". It was folded in as follows: -- **Leave Chat restored on C · Private.** Also removed there: the redundant "See all 5" row and the slab behind the CTA. -- **CTA fade.** B gets the shipped fade, and B's cover is shortened so content clears the CTA. -- **B · Public logo:** the "f" glyph read as Facebook's logo, so it was replaced. -- **Copy:** "Invite only" became "The host approves who joins"; "Favourite groups" became the shipped "Favorite Public - Groups". -- **Public "22 members":** the chevron is gone, because non-members have no roster. -- **Hit areas:** Share is 44pt, and C's share circle has no glass. - -Kept against the critique: -- **"Your Balance" on public groups:** it ships today (`ChatProfileScreen.swift:453`). -- **Pure black page:** Aiden chose #000000 on 2026-10-28. The critic read `backgroundMain` #19191A in design-standard - §1, so that doc is out of date. - -**Not drawn yet (round 2, for the chosen direction):** -- **Group opened from its chat:** no pinned CTA, and Leave Chat ends the scroll. This is the most common route. -- **User CTA states:** Start Chatting (fee unknown), Open Chat (DM), Unblock, and own profile (no CTA). -- **Private CTA states:** Request to Join; "Requested Xm ago" plus Cancel Request (two controls in one slot); - Join Group (invite preview, Brandon); and the host with the Join Requests card. -- **Empty and fallback states:** loading, empty bio, no favourites, long title, and AX5. - -## 9b. Round 2: states and social links (2026-10-28) - -The sections are now 2490×3340 each: A at y 0, B at y 3500, C at y 7000. Row 1 is unchanged except for the X link. Row 2 -holds the User states (Own profile / Already chatting / Blocked). Row 3 holds the Group states (Private requester / -Private requested / Private host / Public opened from its chat / Private invite preview). - -| Direction | User states | Group states | -|---|---|---| -| A | 840:1015 · 840:1153 · 840:1278 | 840:1410 · 840:1573 · 840:1749 · 840:1942 · 840:2091 | -| B | 842:1788 · 842:1951 · 842:2096 | 842:2248 · 842:2376 · 842:2517 · 842:2688 · 842:2864 | -| C | 846:2571 · 846:2735 · 846:2891 | 846:3054 · 846:3252 · 846:3463 · 846:3673 · 846:3856 | - -**Social links.** The proto has X only: `UserProfile.social_profiles`, at most one item, an `XProfile` with username, name, -`verified_type` and `follower_count`. The `LinkSocialAccount` and `UnlinkSocialAccount` RPCs exist. Swift `Profile` -doesn't map any of it yet, and Instagram and the other networks are absent. - -How the frames use it: -- X is drawn with sample data (@tedlivingston, verified, 12.4K followers). -- A shows it as an inline link, B as a centred chip, and C as a row in the details card. -- The own profile shows "Connect X". -- Questions for the dev and backend: map `social_profiles`; add the link flow; other networks need backend fields. - -**CTA by state** (labels as shipped): - -| State | CTA | -|---|---| -| Own profile | none (Edit Profile and Share sit at the header) | -| Already chatting | Open Chat | -| Blocked | Unblock, plus a Blocked chip | -| Private requester | Request to Join, with the roster hidden | -| Private requested | the approval line, a disabled "Requested 2h ago" and a subtle Cancel Request | -| Private host | Edit Group plus a Join Requests card ("3 people are waiting"), then Open Chat | -| Public opened from its chat | no pinned CTA, and Leave Chat ends the scroll | -| Private invite preview | Join Group (Brandon's in-flight design), 48 members, "+38 more" | - -Deviations noted by the builder: -- On the host frames in A and C, Leave Chat sits below the fold. -- In B, opened from its chat, Leave Chat ends the scroll (as today) instead of moving to the ⋯ menu as B's round 1 had it. -- To keep content clear of the fade, B's body moved up and C's card was trimmed to 196. - -No critique was run on round 2 (Aiden asked for one agent). Run `/critique` on the chosen direction before code. - -## 9c. Direction D · Mix (Aiden's steer, 2026-10-28) - -Aiden wants the layout to vary by type: -- **Users** get a full-screen poster made from the profile photo, melting into grouped fields, with no cover. -- **Public groups** keep a cover. -- **Private groups** drop the cover. - -All three share left-aligned identity and C's grouped cards. - -Section `852:3443` (x 0, y 10500, 1526×2340): - -| Row | Frames | -|---|---| -| 1 | User · Poster 852:3447 · Private · Member 852:3603 · Public · Non-member 852:3774 | -| 2 | User · No photo 855:3817 · User · Own profile 855:4001 · User · Scrolled 855:4202 | - -How it's built: -- **Melt:** an alpha-only `bg/primary` mask over the lower 45% of the poster (y 308–560), 11 smootherstep stops, plus a - black legibility scrim peaking at 55%. No band. -- **No photo:** the card colour (`tipCardCustomization.colorHex`) with a 120pt monogram. -- **Scrolled:** the poster leaves with a +60 parallax, and the inline nav title takes over. - -Questions for the dev: -1. Is the uploaded `profile_picture` resolution enough for full-screen? -2. Should the card colour be the fallback when there's no photo? -3. Today's content only scrolls about 180pt, so the collapsed title would never show. That needs content or an inset decision. -4. Private groups still have `cover_picture` in the data; it's just not shown here. - -Rest states: -- The User poster's Favorite Groups card sits under the CTA fade. It's meant to scroll. -- On the private page, Leave Chat brushes the fade. - -### D revision 1 (Aiden's feedback, 2026-10-28) - -- **Private:** members are a 3-column grid of 72pt avatars with names inside a `list/card` card, the host first with a - Hand5Finger badge, under the header "Members · 5". -- **Public requirements:** a "requirement pass" tile with the amount as the hero (`type/amount`), then "of ◉ Jeffy" and - "to join and chat". - - When join == chat it collapses to one tile. The dev needs to confirm that comparison: same currency, same amount. - - The new frame 863:4171 covers Join ≠ Chat, with two tiles ($50 to join, $100 to chat). - - "Your balance" stays as the footer. - - "Jeffy" is a sample currency name. -- **User:** - - "Joined" moves out of the Details card into a `text/tertiary` footnote at the end of the scroll. - - Socials are a horizontally scrolling chip row in the identity block (X with verified mark, Instagram, TikTok; - Central brand icons). Own profile adds a dashed "+ Add" chip. - - The Details card holds only "Minimum to chat". - - Instagram and TikTok are drawn as if linked. They need backend fields and link flows (the proto has X only). - -### D: requirement status (met / unmet), 2026-10-28 - -The question: can a viewer see at a glance whether they meet a public group's requirements? Section D grows to 2008×4666. -Every frame keeps "Open Chat"; the status replaces the "Your balance" footer. - -| Option | Met | Short | Split (join $50 / chat $100, hold $70) | Checking | -|---|---|---|---|---| -| A · status line | 868:4259 | 868:4424 | 868:4586 | 868:4779 | -| B · holding meter | 868:4945 | 868:5112 | 868:5274 | 868:5467 | - -Colour and treatment: -- **Met:** `money/positive` with Central CircleCheck. -- **Short:** "$N more" in `text/warning`, the rest `text/primary`. No error red. -- **B's short bar:** `text/primary` on a `control/fill` track; a met bar caps at 100%. -- **Checking:** `text/tertiary`, no tick. The gate fails open with no rate (`isProvisional`). - -**For the dev:** the verdict and shortfall must come from the existing chat gate (`ConversationGate` `unmetBalance` / -`balanceShortfall`, `GroupProfileState.swift:95`). The view never compares amounts. Option C, a "Buy $X to Join" CTA, -is parked because it changes what the button does. - -### D: public group immersive colour (Aiden's reference: cooking-app recipe page), 2026-10-28 - -The cover melts into a deep colour derived from it, which becomes the whole page background. Secondary text takes a -light tint of the hue instead of grey, the surfaces are a lighter tint, and Share is an outline pill in the tint. The -CTA stays the white capsule, with its fade ramping into the page colour. Row 5 of section D (now 2008×5864). - -| Frame | id | Page / tint / surface | -|---|---|---| -| Blue · Met | 870:5124 | #0B1F4D / #A9C1F5 / #1F315B | -| Warm cover · Met | 870:5287 | #3A1606 / #F2C9A8 / #4A291A | -| Blue · Short | 870:5450 | as Blue; `text/warning` (#FFF383) reads on navy, "You hold" in tint | -| No cover (from avatar) | 870:5610 | #0C1C42, a radial glow of the logo blue behind the avatar | - -Hairlines are the tint at 20%. Every colour is a literal named "· derived from cover (literal)". - -**For the dev: the colour source.** The cover's blurhash already encodes its average colour (the DC term), so the colour -can be derived client-side with no new backend data. Clamp the lightness for contrast, e.g. OKLCH L ≈ 0.20–0.24. -With no cover, derive it from the avatar. - -**Known nit:** on the warm frame, the amber-to-umber melt is a little abrupt and shows a faint band. - -### D: "Chatting recently", tailored, with the host highlighted (2026-10-28) - -Row 6 of section D (now 2008×7218), on the blue immersive page. All three options use the hand badge plus a "Host" -pill. "22 members" is never tappable. - -| Option | id | Host distinction | -|---|---|---| -| 1 · Portrait carousel | 874:5553 | a taller featured first card (128×148) with the avatar ringed in the tint; others 88×112 with "2m ago"; the 4th card clips as the scroll hint | -| 2 · Activity list | 875:5674 | a pinned top row on a lighter surface (#2A3E6C), avatar 42; three recent rows; "+2 more this week · 22 members" | -| 3 · Pulse headline | 876:5789 | a "Hosted by **Ted Livingston**" line, then a facepile, "6 people chatted this week" and the recent names with times | - -Data: `SampleChatters` (host first; `user_profile`, `last_sent_at`, `is_creator`). The counts and recency are derived -client-side from `last_sent_at` (7-day window per the Oct 3 note), so the dev confirms them. Nothing invented: no -mutuals, presence or message previews. - -To fit, padding above the identity block was cut to 4 and the section-title padding to 12, which makes this row's -cover slightly shorter than row 5's. - -### D: pulse refined, plus group bio, then a polish pass (2026-10-28) ★ Aiden: "by far the best yet" - -Row 8 of section D: Default 885:14427, Busy 885:14621, Quiet 885:14819, Nobody this week 885:15013. Row 7 (the refined -carousel 880:5920 / 880:6224 / 880:6528, plus the anatomy board 881:14237) is parked; Aiden prefers low-detail -participants. - -The layout, top to bottom: -1. Cover melting into the derived navy. -2. Avatar, name, "Public group · N members". -3. **Bio** (`Conversation.description`, at most 160 characters, white 16 regular). -4. The outline **Share** pill. -5. **Requirements:** the pass tile with status line A. -6. **Chatting recently:** the pulse card, made of a facepile (up to 5 faces at 36, −10 overlap, plus a "+N" bubble), the - headline "N chatting this week", the meta line "Last message 2m ago · 22 members", a hairline, and a footer row - "Hosted by **Ted Livingston** ›" (avatar 28 with the hand badge, 44 tap height). -7. Open Chat. - -Copy rules by state: -- **Busy:** "84 chatting this week" plus "+79". -- **Quiet:** singular, "1 chatting this week", so the copy must inflect. -- **Nobody this week:** "Quiet this week", no facepile, no bio. - -The bio isn't persisted locally, so it can arrive late after a cold start. Avoid a layout jump (dev). - -**Polish pass:** -- One spacing scale. Identity: avatar → name 12, name → meta 4, meta → bio 8, bio → Share 16. Sections: Share → title - 16, title → card 8, card → title 16. Requirement-tile hairlines have 8 above and below; pulse-card hairlines 12. -- A single outline Share (transparent, 1.5pt tint stroke). The duplicate share circle was removed; row 6's source still - has it. -- The "Chatting recently" title was restored. -- Tile and card padding is 16, with baselines aligned. -- Section titles stay at the kit's bound 14pt. - -**Open:** in Default and Quiet the pulse card ends at about y 805, so its "Hosted by" row sits behind Open Chat at -rest (it scrolls). Either accept the scroll, or move Share into the nav bar as a glass circle next to ⋯ (saves about -60pt and is control-layer glass). That's Aiden's call. - -## 12. Code build 1: public group profile, D row 8 (2026-10-28) - -Aiden chose to let the "Hosted by" row scroll under the CTA. Tuning mode is on, with `Packages/TunerKit` and -`LocalTuner.swift` copied from the swipe worktree, all git-ignored. - -### Scope -- **Changes:** the public branch of `ChatProfileScreen` (`!isPrivate`) only. The private group, the user profile and - every view model are untouched. -- **Unchanged behaviour:** - - every action and destination: share/invite, Edit Group (only when `canEdit`), the currency row → `.currencyInfo`, - portrait → `.userProfile(…, .groupMember)`, Open Chat, Leave Chat; - - the origins and the pinned-bar logic; - - the `requirementsNamed` hold-back; - - accessibility ids; - - the analytics. - -### Colour: "Bleed", ported from `~/Developer/Recipes/Recipes/DesignSystem/Bleed/` -- **Location:** FlipcashUI. It's a package, so there are no pbxproj edits. -- **Files:** BleedRGB, OKLab, BleedColorExtractor (a `CGImage` entry point), BleedPalette, BleedTextStyle, EasedStops, - BleedFade, ObservableBox, BleedColorStore (keyed by a String id), and a modifier taking an id plus image. -- **Defaults** are the values baked in Recipes commit 6e2c13d: kmeans, sample 0.26, saturation 1.2, L dark 0.10, - pull 0.6, warmth 1.0, vibrant text 0.6, fade 183. The Recipes PLAN.md is stale, so don't use its values. -- **Source order:** - 1. the cover bitmap (`profileAvatars.data(for: .groupCover(id))`); - 2. the cover blurhash image (`BlurHashCache`); - 3. the avatar data or blurhash; - 4. otherwise the placeholder (today's black). -- Dark-mode palette only (the app is dark). -- **Tunables:** `// LOCAL-TUNER` lines under `Profile/Public/*` for saturation, L dark, pull, fade height and cover height. - -### Layout (top to bottom) -1. **Cover:** full-bleed, under the status bar, with the Bleed fade into `palette.background`. The page, scroll and - navigation container backgrounds all use `palette.background`, with `toolbarColorScheme` from the palette. -2. **Identity:** - - a left-aligned avatar overlapping the melt, with its ring in `palette.background`; - - the name, large and bold; - - "Public group · N members" (`rosterSummary.memberCount`) in the secondary style; - - the bio (`description`) in white. -3. **Actions:** an outline "Share" pill, 44 tall with a 1.5pt stroke in the secondary colour, doing today's - `isInviting` action. Edit Group (when `canEdit`) uses the same outline pill. The old 38pt circle goes. -4. **Section titles:** "Requirements" and "Chatting recently", 14 semibold, in the secondary style. -5. **Requirement pass tile:** `controlTint` surface, padding 16, with the amount as the hero. - - It reads "of ◉ " (the token icon + name, tappable → `.currencyInfo` exactly as today), then "to join and - chat". If chat ≠ join, there are two tiles side by side. - - **Footer:** today's "Your Balance $X" string, unchanged. The met/unmet status line is **parked**, because it - needs the gate verdict (affordability). -6. **Pulse card:** `controlTint` surface. - - Up to 5 faces at 36 with −10 overlap, each tappable to its profile as today, and a "+N" bubble. - - "N chatting this week" (sample chatters with `lastSentAt` inside 7 days), then "Last message · - N members". - - A hairline, then "Hosted by ›" (→ the creator's profile). - - With nobody chatting: "Quiet this week". -7. **Leave Chat** (members) at the end, unchanged in behaviour. -8. **Pinned CTA:** "Open Chat" in the branch's capsule primary style. On iOS 18 the backdrop fade uses - `palette.background`; on iOS 26 it's the system edge effect. - -### For the dev (in the report and the plan) -- The met/unmet status needs the gate verdict on the profile. -- "N chatting this week" counts a server sample of at most 100. -- Collapsing join == chat compares the two rule values. -- The member count is shown on public groups. -- The description arrives late after a cold start. - -### Code build 1: result (2026-10-29) - -Commits on `feat/ui-profile` (local only): -- `3c2db72e` the Bleed port (FlipcashUI/Theme/Bleed); -- `f0c8e540` the public group profile; -- `f799306f` the design-review fold-in. - -The build passes and all 46 profile tests pass, including the new `PublicGroupCopyTests`. `privateScreen` is identical to -before. - -**Fold-in:** -- A still-loading group renders as private, so a private group never paints as "Public group". -- Faces and the token link get 44pt hit areas. -- Text on tiles is contrast-solved on the control tint (`controlSecondaryStyle`). -- The pulse card is held back with the requirements. -- The bio animation is page-wide and respects Reduce Motion. -- Presses scale to 0.96 with `Haptics.soft()` on touch-down. -- Hairlines are one device pixel. - -**Tunables:** `Profile/Public/{Saturation, L dark, Pull, Fade height, Cover height}`. They must be baked before any PR, -because CI can't see `LocalTuner.swift`. - -**Taste calls still open** (from the code critique): -1. "+N" isn't tappable; the old grid opened every sampled chatter. -2. Leave Chat now follows the content. -3. A 33pt title next to a 40pt amount (layout rule 2). -4. First paint fades from #19191A to the cover colour over 0.25s; the colour could be seeded from the blurhash instead. - -**Parked for the dev / system work:** -- The met/unmet status line. -- One shared capsule CTA style (the screen button height is still open). -- A Bleed row in the standard. -- Device checks: iOS 18 opaque nav bar banding; iOS 26 opened-from-chat with no bottom fade; AX5; long token names. - -## 10. Taste calls after round 1 - -1. Avatar: right of the name (A), centred over the cover (B), or top-left on the card (C). -2. Identity alignment: left-aligned (A, C) or centred (B). -3. Cover: a wash (A), a full-bleed photo (B), or held inside the card (C). Empty cover: a tint from the avatar, or - nothing. -4. Minimum to chat: CTA only (A), or a stat plus the CTA (B, C). -5. Leave Chat: at the end of the scroll (A, C), or in the ⋯ menu (B, needs the dev's OK). Red or `.subtle`. -6. Private members: a full list (A, C), or a facepile (B). -7. Name size: 34 (A), 28 (B) or 24 (C). -8. CTA inset: 32 (the Amount and tray decision of 2026-10-19), or 20 (the content edge, which the critic prefers). -9. Card radius: 12 (standard rule 15), or about 22 (`card/radius`, rows v2 style B and trays). - -The standard is silent on: -- whether a profile counts as a money screen (layout rule 2: money is the largest text); -- section-heading casing; -- a header avatar size token. - -## 11. Log -- 2026-10-28: worktree set up; four surveys and the prior-art report done; Profiles page created; directions proposed. -- 2026-10-28: round 1 built (A, B, C × 3 types); critique run; fix pass folded in. diff --git a/Flipcash/Core/FlipcashApp.swift b/Flipcash/Core/FlipcashApp.swift index 3490f8164..34b02fa0a 100644 --- a/Flipcash/Core/FlipcashApp.swift +++ b/Flipcash/Core/FlipcashApp.swift @@ -22,17 +22,17 @@ struct FlipcashApp: App { WindowGroup { #if DEBUG if MotionSandbox.isRequested { - MotionSandbox().tunerFloating() // LOCAL-TUNER - } else if DialogSandbox.isRequested { // LOCAL-TUNER - DialogSandbox().tunerFloating() // LOCAL-TUNER - } else if TraySandbox.isRequested { // LOCAL-TUNER - TraySandbox().tunerFloating() // LOCAL-TUNER - } else if SwipeSandbox.isRequested { // LOCAL-TUNER - SwipeShowcaseHost().tunerFloating() // LOCAL-TUNER + MotionSandbox() + } else if DialogSandbox.isRequested { + DialogSandbox() + } else if TraySandbox.isRequested { + TraySandbox() + } else if SwipeSandbox.isRequested { + SwipeShowcaseHost() } else if let benchmark = ScrollBenchmark.requested { benchmark } else { - mainScene.tunerFloating(groups: ["Profile"]) // LOCAL-TUNER + mainScene } #else mainScene @@ -190,50 +190,50 @@ private extension View { } } -#if DEBUG // LOCAL-TUNER -/// Real `DialogItem`s through the real `.dialog(item:)` seam, with no sign-in (`--dialog-sandbox`, // LOCAL-TUNER -/// `--dialog-sandbox-open `). Actions do nothing. // LOCAL-TUNER -private struct DialogSandbox: View { // LOCAL-TUNER - - static var isRequested: Bool { ProcessInfo.processInfo.arguments.contains("--dialog-sandbox") } // LOCAL-TUNER - - @State private var dialogItem: DialogItem? // LOCAL-TUNER - - private let trustedWebsites = TrustedWebsites(defaults: UserDefaults(suiteName: "dialog-sandbox")!) // LOCAL-TUNER - - private var items: [(String, () -> DialogItem)] { // LOCAL-TUNER - [ // LOCAL-TUNER - ("Log out (alert, destructive)", { // LOCAL-TUNER - .alert(title: "Are You Sure You Want To Log Out?", subtitle: "You can get into this account using your Access Key") { // LOCAL-TUNER - DialogAction.destructive("Log Out") {} // LOCAL-TUNER - DialogAction.cancel() // LOCAL-TUNER - } // LOCAL-TUNER - }), // LOCAL-TUNER - ("Leaving Flipcash (info, checkbox)", { .leavingFlipcash(host: "example.com", trustedWebsites: trustedWebsites) {} }), // LOCAL-TUNER - ("Error (tracked, default OK)", { .error(title: "Something Went Wrong", subtitle: "Please try again later") }), // LOCAL-TUNER - ("Success (not dismissable)", { .success(title: "Report Sent", subtitle: "Thanks. We'll take a look") }), // LOCAL-TUNER - ] // LOCAL-TUNER - } // LOCAL-TUNER - - var body: some View { // LOCAL-TUNER - VStack(spacing: 12) { // LOCAL-TUNER - ForEach(items.indices, id: \.self) { index in // LOCAL-TUNER - Button(items[index].0) { dialogItem = items[index].1() } // LOCAL-TUNER - .buttonStyle(.filled20Compact) // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER - .padding(20) // LOCAL-TUNER - .frame(maxWidth: .infinity, maxHeight: .infinity) // LOCAL-TUNER - .background(Color.backgroundMain) // LOCAL-TUNER - .dialog(item: $dialogItem) // LOCAL-TUNER - .preferredColorScheme(.dark) // LOCAL-TUNER - .task { // LOCAL-TUNER - let arguments = ProcessInfo.processInfo.arguments // LOCAL-TUNER - guard let i = arguments.firstIndex(of: "--dialog-sandbox-open"), i + 1 < arguments.count, // LOCAL-TUNER - let index = Int(arguments[i + 1]), items.indices.contains(index) else { return } // LOCAL-TUNER - try? await Task.sleep(for: .seconds(0.5)) // LOCAL-TUNER - dialogItem = items[index].1() // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER -} // LOCAL-TUNER -#endif // LOCAL-TUNER +#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 a167f2ec1..26dcd8ed5 100644 --- a/Flipcash/Core/Screens/Conversation/AttachMenu.swift +++ b/Flipcash/Core/Screens/Conversation/AttachMenu.swift @@ -60,11 +60,10 @@ struct AttachMenu: View { panel.animate { $0.toggle() } } label: { Image.icon(.plusLarge) - .iconSize(ComposerTuning.plusIconSize) // LOCAL-TUNER - .foregroundStyle(ComposerTuning.plusIconColor) // LOCAL-TUNER + .iconSize(22) + .foregroundStyle(Color.textMain) .frame(width: BarMetrics.accessorySize, height: BarMetrics.accessorySize) - .background(ComposerTuning.plusStyle == .filled ? ComposerTuning.plusFill : .clear, in: Circle()) // LOCAL-TUNER - .overlay(Circle().strokeBorder(ComposerTuning.plusStyle == .outlined ? ComposerTuning.plusFill : .clear, lineWidth: 1.5)) // LOCAL-TUNER + .background(Color.white.opacity(0.08), in: Circle()) .contentShape(Circle()) } .buttonStyle(.plain) diff --git a/Flipcash/Core/Screens/Conversation/AttachSurface.swift b/Flipcash/Core/Screens/Conversation/AttachSurface.swift index 4cf6daa73..8d075da45 100644 --- a/Flipcash/Core/Screens/Conversation/AttachSurface.swift +++ b/Flipcash/Core/Screens/Conversation/AttachSurface.swift @@ -248,8 +248,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.icon(.plusLarge) - .iconSize(ComposerTuning.plusIconSize) // LOCAL-TUNER - .foregroundStyle(ComposerTuning.plusIconColor) // LOCAL-TUNER + .iconSize(22) + .foregroundStyle(Color.textMain) .frame(width: plus.width, height: plus.height) .accessibilityHidden(true) } diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 9491ce7de..552e43ad4 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -182,9 +182,10 @@ struct JumpToBottomOverlay: View { 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: true) // LOCAL-TUNER: same column resting and typing - - JumpToBottomButtonView.diameter / 2 + ComposerTuning.jumpNudge // LOCAL-TUNER + // Centred on the resting trailing control's column, resting and typing alike, then moved + // 32pt toward the screen's edge. + let inset = BarMetrics.trailingControlCentreInset(isCompact: true) + - JumpToBottomButtonView.diameter / 2 - 32 JumpToBottomButtonView(unreadCount: jump.unreadCount, action: jump.onTap) .frame(width: JumpToBottomButtonView.diameter, height: JumpToBottomButtonView.diameter) // Opacity only: a glass effect under a scale effect drew a shrunken copy of the screen. @@ -215,34 +216,33 @@ private let replySpring = ChatMotion.replySurface.animation /// the field the controls are field-sized, not standard-button-sized. enum BarMetrics { nonisolated static let fieldMinHeight: CGFloat = 34 - nonisolated static var fieldVerticalPadding: CGFloat { Tuner.value("Chat v2 Composer/Field padding Y", default: 8, in: 0...20, unit: .points) } // LOCAL-TUNER + nonisolated static let fieldVerticalPadding: CGFloat = 8 /// The field's padding on every side: where `+` stands in from the field's leading edge, and so /// how far the open attach menu reaches back past `+` to line up with the field. - nonisolated static var fieldPadding: CGFloat { Tuner.value("Chat v2 Composer/Field padding X", default: 8, in: 0...20, unit: .points) } // LOCAL-TUNER + nonisolated static let fieldPadding: CGFloat = 8 static let cornerRadius: CGFloat = 14 /// The composer field's corner, rounder than the bar's other controls. - nonisolated static var fieldCornerRadius: CGFloat { fieldPadding + accessorySize / 2 } // LOCAL-TUNER + nonisolated static let fieldCornerRadius: CGFloat = fieldPadding + accessorySize / 2 /// The height of every bar control: a single-line field plus its padding, and the height the /// Send Cash button morphs at while there is a composer beside it. - nonisolated static var contentHeight: CGFloat { fieldMinHeight + fieldVerticalPadding * 2 } // LOCAL-TUNER + nonisolated static let contentHeight: CGFloat = fieldMinHeight + fieldVerticalPadding * 2 /// The diameter of the controls beside the text: `+`, the Send Cash button and the send button. - nonisolated static var accessorySize: CGFloat { Tuner.value("Chat v2 Composer/Button size", default: 34, in: 24...48, unit: .points) } // LOCAL-TUNER + nonisolated static let accessorySize: CGFloat = 34 /// The bar's own margin around its controls, above and below. - static var contentPadding: CGFloat { Tuner.value("Chat v2 Composer/Bar padding Y", default: 8, in: 0...24, unit: .points) } // LOCAL-TUNER + static let contentPadding: CGFloat = 8 /// The margin between the bar's controls and the screen's sides while the keyboard is up. - static var edgeInset: CGFloat { Tuner.value("Chat v2 Composer/Side inset typing", default: 12, in: 0...40, unit: .points) } // LOCAL-TUNER + static let edgeInset: CGFloat = 12 /// How far into the home indicator's safe area the compact bar rests while the keyboard is down. - static var compactDrop: CGFloat { Tuner.value("Chat v2 Composer/Rest drop", default: 8, in: 0...30, unit: .points) } // LOCAL-TUNER + static let compactDrop: CGFloat = 8 /// The margin between the compact bar's controls and the screen's sides. - static var compactInset: CGFloat { Tuner.value("Chat v2 Composer/Side inset resting", default: 32, in: 0...48, unit: .points) } // LOCAL-TUNER + static let compactInset: CGFloat = 32 /// The gap between the field and the `$` beside it, the same compact and composing. - nonisolated static var controlSpacing: CGFloat { Tuner.value("Chat v2 Composer/Field to $ gap", default: 12, in: 0...24, unit: .points) } // LOCAL-TUNER - /// The gap between the jump-to-bottom button's bottom edge and the top edge of `$`: twice the - /// field's gap to `$`. - static var jumpButtonGap: CGFloat { Tuner.value("Chat v2 Jump/Gap above $", default: 23.6, in: 0...60, unit: .points) } // LOCAL-TUNER + nonisolated static let controlSpacing: CGFloat = 12 + /// The gap between the jump-to-bottom button's bottom edge and the top edge of `$`. + static let jumpButtonGap: CGFloat = 23.6 /// 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 var jumpButtonGapAboveBar: CGFloat { jumpButtonGap - contentPadding } // LOCAL-TUNER + static let jumpButtonGapAboveBar: CGFloat = jumpButtonGap - contentPadding /// The margin between the bar's controls and the screen's sides: ``compactInset`` while the bar /// rests compact, ``edgeInset`` while composing. @@ -259,7 +259,7 @@ enum BarMetrics { /// How far a toast comes back down into the compact bar's measured height, so it rests 8pt above /// the controls: the bar's top margin, its drop into the safe area, and the 4pt the toast host's /// 12pt padding exceeds that gap. - static var toastOverlap: CGFloat { contentPadding + compactDrop + 4 } // LOCAL-TUNER + static let toastOverlap: CGFloat = contentPadding + compactDrop + 4 } /// What stands outside the message field, beside it. @@ -391,7 +391,7 @@ struct ConversationBottomBar: View { .combined(with: .scale(scale: 0.4)) /// The gap between the field and the control beside it: cancel-edit on its left, `$` on its right. The composer's glass joins across no /// more than this, so `$` stays bridged to the field while it travels and pinches off at rest. - static var leadingSpacing: CGFloat { BarMetrics.controlSpacing } // LOCAL-TUNER + static let leadingSpacing: CGFloat = BarMetrics.controlSpacing /// How close the composer's glass shapes come before they flow together: under the resting gap, /// so `$` is bridged to the field while it travels and stands clear of it at rest. private static let glassJoinDistance: CGFloat = 8 @@ -1149,6 +1149,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. @@ -1228,7 +1230,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(ComposerTuning.font) // LOCAL-TUNER + .font(Self.draftFont) .foregroundStyle(Color.textMain) .tint(.white) .lineLimit(1...7) @@ -1271,7 +1273,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(ComposerTuning.font) // LOCAL-TUNER + .font(Self.draftFont) .lineLimit(1) .fixedSize() .hidden() @@ -1551,21 +1553,3 @@ struct ComposerImageDropDelegate: DropDelegate { return true } } - -// LOCAL-TUNER: composer and jump-button controls that aren't plain BarMetrics numbers. -enum ComposerTuning { // LOCAL-TUNER - static var textSize: CGFloat { Tuner.value("Chat v2 Composer/Text size", default: 17, in: 13...22, unit: .points) } // LOCAL-TUNER - static var textWeight: ChatTitleWeight { Tuner.value("Chat v2 Composer/Text weight", default: ChatTitleWeight.regular) } // LOCAL-TUNER - static var font: Font { .default(size: textSize, weight: textWeight.weight) } // LOCAL-TUNER - static var plusStyle: ComposerPlusStyle { Tuner.value("Chat v2 Composer/Plus style", default: ComposerPlusStyle.filled) } // LOCAL-TUNER - static var plusFill: Color { Tuner.value("Chat v2 Composer/Plus fill", default: Color.white.opacity(0.08)) } // LOCAL-TUNER - static var plusIconSize: CGFloat { Tuner.value("Chat v2 Composer/Plus icon size", default: 22, in: 12...30, unit: .points) } // LOCAL-TUNER - static var plusIconColor: Color { Tuner.value("Chat v2 Composer/Plus icon color", default: Color.textMain) } // LOCAL-TUNER - /// Moves the jump button sideways from its column: positive goes left. // LOCAL-TUNER - static var jumpNudge: CGFloat { Tuner.value("Chat v2 Jump/Horizontal nudge", default: -32, in: -40...40, unit: .points) } // LOCAL-TUNER -} // LOCAL-TUNER - -enum ComposerPlusStyle: String, TunableEnum { // LOCAL-TUNER - case filled, outlined, bare // LOCAL-TUNER -} // LOCAL-TUNER - diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 9def1e2ae..9f7ac6ad4 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -1607,22 +1607,8 @@ private struct ConversationTitleItem: View { } } -enum ChatAvatarMode: String, TunableEnum { // LOCAL-TUNER - case real, initials, link // LOCAL-TUNER -} // LOCAL-TUNER - -enum ChatTitleWeight: String, TunableEnum { // LOCAL-TUNER - case regular, medium, semibold // LOCAL-TUNER - var weight: Font.Weight { switch self { case .regular: .regular; case .medium: .medium; case .semibold: .semibold } } // LOCAL-TUNER -} // LOCAL-TUNER - private struct ConversationTitleLabel: View { - // LOCAL-TUNER: first letters of the first two words, for the initials preview. - private static func initials(_ name: String) -> String { // LOCAL-TUNER - name.split(separator: " ").prefix(2).compactMap(\.first).map(String.init).joined().uppercased() // LOCAL-TUNER - } // LOCAL-TUNER - let title: String let subtitle: String? let contact: ResolvedContact? @@ -1632,42 +1618,22 @@ private struct ConversationTitleLabel: View { let width: CGFloat var mute: ConversationMuteState? = nil - @Tunable("Chat v2 Avatar/Mode") var avatarMode = ChatAvatarMode.real // LOCAL-TUNER - @Tunable("Chat v2 Avatar/Image link") var avatarURL = "https://picsum.photos/seed/flipcash/200" // LOCAL-TUNER - @Tunable("Chat v2 Avatar/Initials top") var initialsTop = Color(red: 0.25, green: 0.25, blue: 0.25) // LOCAL-TUNER - @Tunable("Chat v2 Avatar/Initials bottom") var initialsBottom = Color(red: 0.13, green: 0.13, blue: 0.13) // LOCAL-TUNER - @Tunable("Chat v2 Avatar/Initials color") var initialsColor = Color.white // LOCAL-TUNER - @Tunable("Chat v2 Avatar/Initials size", in: 0.2...0.6) var initialsScale: CGFloat = 0.36 // LOCAL-TUNER - @Tunable("Chat v2 Title/Avatar size", in: 28...56, unit: .points) var avatarSize: CGFloat = 44 // LOCAL-TUNER - @Tunable("Chat v2 Title/Avatar to name", in: 4...20, unit: .points) var avatarSpacing: CGFloat = 12 // LOCAL-TUNER - @Tunable("Chat v2 Title/Name size", in: 13...22, unit: .points) var nameSize: CGFloat = 17 // LOCAL-TUNER - @Tunable("Chat v2 Title/Name weight") var nameWeight = ChatTitleWeight.semibold // LOCAL-TUNER - var body: some View { - HStack(spacing: avatarSpacing) { // LOCAL-TUNER + HStack(spacing: 12) { ContactAvatarView( id: contact?.contactId ?? conversationID?.description ?? title, displayName: title, - imageData: avatarMode == .real ? imageData : nil, // LOCAL-TUNER - blurhash: avatarMode == .real ? blurhash : nil, // LOCAL-TUNER - size: avatarSize // LOCAL-TUNER + imageData: imageData, + blurhash: blurhash, + size: 44 ) - .overlay { // LOCAL-TUNER - if avatarMode == .initials { // LOCAL-TUNER - Circle().fill(LinearGradient(colors: [initialsTop, initialsBottom], startPoint: .top, endPoint: .bottom)) // LOCAL-TUNER - .overlay(Text(Self.initials(title)).font(.default(size: avatarSize * initialsScale, weight: .semibold)).foregroundStyle(initialsColor)) // LOCAL-TUNER - } else if avatarMode == .link, let url = URL(string: avatarURL) { // LOCAL-TUNER - AsyncImage(url: url) { $0.resizable().scaledToFill() } placeholder: { Color.clear } // LOCAL-TUNER - .frame(width: avatarSize, height: avatarSize).clipShape(Circle()) // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER .accessibilityHidden(true) // The 44pt avatar already sets the bar's height, so the second line costs nothing and a // titled-only DM keeps the layout it has (nodes 10125:19191-19194). VStack(alignment: .leading, spacing: 0) { HStack(spacing: 4) { Text(title) - .font(.default(size: nameSize, weight: nameWeight.weight)) // LOCAL-TUNER + .font(.default(size: 17, weight: .semibold)) .foregroundStyle(Color.textMain) .lineLimit(1) MuteBell(mute) diff --git a/Flipcash/Core/Screens/Main/Home/HomeTab.swift b/Flipcash/Core/Screens/Main/Home/HomeTab.swift index 682ca0632..002968e41 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTab.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTab.swift @@ -29,7 +29,6 @@ enum HomeTab: Int, CaseIterable, Identifiable, Hashable { /// The tab's Central glyph: filled when selected, outlined otherwise, per the tab bar spec. func icon(isSelected: Bool) -> Icon { - let isSelected = isSelected && TabBarItemStyle.fillsSelected // LOCAL-TUNER switch self { case .scan: return isSelected ? .kikCodeFilled : .kikCode case .chat: return isSelected ? .chatBubblesFilled : .chatBubbles diff --git a/Flipcash/Core/Screens/Main/Home/TabBarBadgedIcon.swift b/Flipcash/Core/Screens/Main/Home/TabBarBadgedIcon.swift index b545db0b1..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 var glyphSize: CGFloat { TabBarItemStyle.glyphSize } // LOCAL-TUNER + 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). @@ -28,18 +28,18 @@ enum TabBarBadgedIcon { /// and each call would otherwise run two `ImageRenderer` passes. static func itemImages(for tab: HomeTab, count: Int) -> TabBarProfilePhoto.ItemImages? { guard count > 0 else { return nil } - if let memoized, memoized.tab == tab, memoized.count == count, memoized.size == glyphSize, memoized.opacity == TabBarItemStyle.unselectedOpacity, memoized.fills == TabBarItemStyle.fillsSelected { return memoized.images } // LOCAL-TUNER + if let memoized, memoized.tab == tab, memoized.count == count { return memoized.images } guard let normal = image(of: icon(for: tab, count: count, isSelected: false)), let selected = image(of: icon(for: tab, count: count, isSelected: true)) else { return nil } let images = TabBarProfilePhoto.ItemImages(normal: normal, selected: selected) - memoized = (tab, count, glyphSize, TabBarItemStyle.unselectedOpacity, TabBarItemStyle.fillsSelected, images) // LOCAL-TUNER + memoized = (tab, count, images) return images } - private static var memoized: (tab: HomeTab, count: Int, size: CGFloat, opacity: Double, fills: Bool, images: TabBarProfilePhoto.ItemImages)? // LOCAL-TUNER + 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 (dimmed when unselected), with the diff --git a/Flipcash/Core/Screens/Main/Home/TabBarItemStyle.swift b/Flipcash/Core/Screens/Main/Home/TabBarItemStyle.swift index 3220f2d50..3deb62bca 100644 --- a/Flipcash/Core/Screens/Main/Home/TabBarItemStyle.swift +++ b/Flipcash/Core/Screens/Main/Home/TabBarItemStyle.swift @@ -9,36 +9,26 @@ 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, unless -/// ``showsNativeTitles``, its title. The bar is left only the selection lens. +/// both states, so each item image carries its own colour, dimming and title. +/// The bar is left only the selection lens. @MainActor enum TabBarItemStyle { - enum TitleWeight: TunableEnum { case regular, medium, semibold } // LOCAL-TUNER - /// The point size every tab glyph is drawn at, badged or not. - static var glyphSize: CGFloat { Tuner.shared.value("Tab bar/Icon size", default: CGFloat(28), in: 16...40, unit: .points) } // LOCAL-TUNER + static let glyphSize: CGFloat = 28 /// Moves the glyph and its title down (positive) or up within the bar. - static var verticalOffset: CGFloat { Tuner.shared.value("Tab bar/Vertical offset", default: CGFloat(0), in: -12...12, unit: .points) } // LOCAL-TUNER + static let verticalOffset: CGFloat = 0 /// The opacity of the glyph and title of the tabs that aren't selected. - static var unselectedOpacity: Double { Tuner.shared.value("Tab bar/Unselected opacity", default: 0.5, in: 0...1) } // LOCAL-TUNER - /// Whether the selected tab swaps to its filled glyph. - static var fillsSelected: Bool { Tuner.shared.value("Tab bar/Fill selected icon", default: true) } // LOCAL-TUNER - /// Whether the tabs carry titles under their glyphs. - static var showsTitles: Bool { Tuner.shared.value("Tab bar/Show titles", default: true) } // LOCAL-TUNER - /// Whether the titles are the system's own, which can't be dimmed, rather than drawn into the image. - static var usesSystemTitles: Bool { Tuner.shared.value("Tab bar/System titles", default: false) } // LOCAL-TUNER + static let unselectedOpacity: Double = 0.5 /// The drawn title's point size. - static var titleSize: CGFloat { Tuner.shared.value("Tab bar/Title size", default: CGFloat(10), in: 8...16, unit: .points) } // LOCAL-TUNER - /// The drawn title's weight. - static var titleWeight: TitleWeight { Tuner.shared.value("Tab bar/Title weight", default: TitleWeight.semibold) } // LOCAL-TUNER + static let titleSize: CGFloat = 10 /// The space between the glyph and the drawn title. - static var titleGap: CGFloat { Tuner.shared.value("Tab bar/Title gap", default: CGFloat(2), in: 0...16, unit: .points) } // LOCAL-TUNER + static let titleGap: CGFloat = 2 /// The opacity of the bar's tint over its Liquid Glass. - static var backgroundOpacity: Double { Tuner.shared.value("Tab bar/Background opacity", default: 0.5, in: 0...1) } // LOCAL-TUNER + static let backgroundOpacity: Double = 0.5 - /// Whether the bar shows the system's titles under the item images. - static var showsNativeTitles: Bool { showsTitles && usesSystemTitles } + /// 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. @@ -72,25 +62,23 @@ enum TabBarItemStyle { /// 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? = showsTitles && !usesSystemTitles - ? NSAttributedString(string: title, attributes: [.font: titleFont, .foregroundColor: textColor]) - : nil + let text = NSAttributedString(string: title, attributes: [.font: titleFont, .foregroundColor: textColor]) // The glyph's alignment rect leaves out a badge's overhang, so the pair 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 = text.map { CGSize(width: ceil($0.size().width), height: ceil($0.size().height)) } ?? .zero + let textSize = CGSize(width: ceil(text.size().width), height: ceil(text.size().height)) let width = max(glyph.size.width, textSize.width) - let contentHeight = glyph.size.height + (text == nil ? 0 : titleGap + textSize.height) + let contentHeight = glyph.size.height + 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) return UIGraphicsImageRenderer(size: canvas).image { _ in glyph.draw(at: glyphOrigin, blendMode: .normal, alpha: glyphAlpha) - text?.draw(at: CGPoint( + text.draw(at: CGPoint( x: (width - textSize.width) / 2, y: top + insets.top + glyphHeight + titleGap )) @@ -98,11 +86,5 @@ enum TabBarItemStyle { .withRenderingMode(.alwaysOriginal) } - private static var titleFont: UIFont { - switch titleWeight { - case .regular: UIFont.default(size: titleSize, weight: .regular) - case .medium: UIFont.default(size: titleSize, weight: .medium) - case .semibold: UIFont.default(size: titleSize, weight: .semibold) - } - } + private static let titleFont = UIFont.default(size: titleSize, weight: .semibold) } diff --git a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift index d6af8d6f3..bec14a568 100644 --- a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift +++ b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift @@ -68,23 +68,22 @@ struct RecipientRowBody: View { @ViewBuilder let trailing: Trailing var body: some View { - HStack(spacing: ChatListTuning.avatarGap) { // LOCAL-TUNER + HStack(spacing: 12) { ContactAvatarView( id: avatarID, displayName: title, imageData: imageData, blurhash: blurhash, - size: ChatListTuning.avatarSize // LOCAL-TUNER + size: 52 ) - VStack(alignment: .leading, spacing: ChatListTuning.lineGap) { // LOCAL-TUNER - HStack(alignment: ChatListTuning.dateBaseline ? .firstTextBaseline : .top, spacing: 6) { // LOCAL-TUNER + 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. HStack(spacing: 0) { Text(title) - .font(.default(size: ChatListTuning.titleSize, weight: ChatListTuning.titleWeight.weight)) // LOCAL-TUNER - .foregroundStyle(ChatListTuning.titleColor) // LOCAL-TUNER - .tracking(ChatListTuning.titleTracking) // LOCAL-TUNER + .font(.appTextMedium) + .foregroundStyle(Color.textMain) .lineLimit(1) MuteBell(mute, leadingGap: 6) .fixedSize() @@ -95,11 +94,11 @@ struct RecipientRowBody: View { } } if let subtitle { - Text(ChatListTuning.styledPreview(subtitle)) // LOCAL-TUNER - .font(.default(size: ChatListTuning.previewSize, weight: ChatListTuning.previewWeight.weight)) // LOCAL-TUNER - .foregroundStyle(ChatListTuning.previewColor) // LOCAL-TUNER - .tracking(ChatListTuning.previewTracking) // LOCAL-TUNER - .lineLimit(ChatListTuning.previewLines) // LOCAL-TUNER + Text(Self.styledPreview(subtitle)) + .font(.default(size: 14, weight: .regular)) + .foregroundStyle(Color.textSecondary) + .tracking(0.16) + .lineLimit(2) .contentTransition(.opacity) .transition(.opacity) } @@ -114,6 +113,18 @@ struct RecipientRowBody: View { } .contentShape(Rectangle()) } + + /// The preview with a leading "Name:" set apart in bold. + private static func styledPreview(_ text: AttributedString) -> AttributedString { + let plain = String(text.characters) + guard let colon = plain.firstIndex(of: ":"), plain.distance(from: plain.startIndex, to: colon) <= 28, + let end = text.characters.index(text.startIndex, offsetBy: plain.distance(from: plain.startIndex, to: colon) + 1, limitedBy: text.endIndex) + else { return text } + var styled = text + styled[text.startIndex.. some View { self .buttonStyle(.plain) - .listRowInsets(EdgeInsets(top: ChatListTuning.rowPaddingY, leading: ChatListTuning.rowPaddingX, bottom: ChatListTuning.rowPaddingY, trailing: ChatListTuning.rowPaddingX)) // LOCAL-TUNER - .listRowSeparator(ChatListTuning.separators ? .automatic : .hidden) // LOCAL-TUNER - .alignmentGuide(.listRowSeparatorLeading) { _ in ChatListTuning.separatorInset } // LOCAL-TUNER + .listRowInsets(EdgeInsets(top: 14, leading: 20, bottom: 14, trailing: 20)) + .listRowSeparator(.automatic) + .alignmentGuide(.listRowSeparatorLeading) { _ in 56 } .listRowBackground(Color.clear) .listRowSeparatorTint(.rowSeparator) .accessibilityElement(children: .ignore) @@ -133,146 +144,44 @@ 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: ChatListTuning.dateGap) { // LOCAL-TUNER + HStack(spacing: 6) { if isUnknown { Text("Unknown Contact") .fixedSize(horizontal: true, vertical: false) .pill() } else { - Text(ChatListTuning.date(timestamp)) // LOCAL-TUNER - .font(.default(size: ChatListTuning.dateSize, weight: ChatListTuning.dateWeight.weight)) // LOCAL-TUNER - .tracking(ChatListTuning.dateTracking) // LOCAL-TUNER - .foregroundStyle(hasUnread && ChatListTuning.dateTintsUnread ? Color.unreadIndicator : ChatListTuning.dateColor) // LOCAL-TUNER + Text(Self.label(for: timestamp)) + .font(.default(size: 14, weight: .regular)) + .foregroundStyle(hasUnread ? Color.unreadIndicator : Color.textSecondary) } - if let count, ChatListTuning.unreadStyle == .count { - UnreadCountPill(count: count) - } else if hasUnread, ChatListTuning.unreadStyle != .none { + if hasUnread { Circle() .fill(Color.unreadIndicator) - .frame(width: ChatListTuning.unreadDotSize, height: ChatListTuning.unreadDotSize) // LOCAL-TUNER - } else if ChatListTuning.showsChevron { // LOCAL-TUNER - Image(systemName: "chevron.right") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 9, height: 12) - .foregroundStyle(Color.textSecondary) + .frame(width: 9, height: 9) } } } -} - -/// 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) + /// The time today, "Yesterday", a short weekday within the past week, and a short month and + /// day before that. + private static func label(for date: Date) -> String { + let calendar = Calendar.current + if calendar.isDateInToday(date) { + return date.formatted(date: .omitted, time: .shortened) + } + if calendar.isDateInYesterday(date) { return "Yesterday" } + let weekAgo = calendar.date(byAdding: .day, value: -6, to: calendar.startOfDay(for: .now))! + return date > weekAgo + ? date.formatted(.dateTime.weekday(.abbreviated)) + : date.formatted(.dateTime.month(.abbreviated).day()) } } - -// LOCAL-TUNER: every value in a chat list row, for tuning. Groups start "Chat list" so the panel can scope to them. -enum ChatListTuning { // LOCAL-TUNER - static var avatarSize: CGFloat { Tuner.value("Chat list Row/Avatar size", default: 52, in: 28...72, unit: .points) } // LOCAL-TUNER - static var avatarGap: CGFloat { Tuner.value("Chat list Row/Avatar to text", default: 12, in: 4...24, unit: .points) } // LOCAL-TUNER - static var rowPaddingY: CGFloat { Tuner.value("Chat list Row/Padding Y", default: 14, in: 4...28, unit: .points) } // LOCAL-TUNER - static var rowPaddingX: CGFloat { Tuner.value("Chat list Row/Padding X", default: 20, in: 8...32, unit: .points) } // LOCAL-TUNER - static var separators: Bool { Tuner.value("Chat list Row/Separators", default: true) } // LOCAL-TUNER - /// Where the separator starts: 0 is the row's edge; avatar + gap lines it up with the text. // LOCAL-TUNER - static var separatorInset: CGFloat { Tuner.value("Chat list Row/Separator inset", default: 56, in: 0...100, unit: .points) } // LOCAL-TUNER - static var lineGap: CGFloat { Tuner.value("Chat list Row/Title to preview", default: 3, in: 0...12, unit: .points) } // LOCAL-TUNER - - static var titleSize: CGFloat { Tuner.value("Chat list Title/Size", default: 16, in: 12...22, unit: .points) } // LOCAL-TUNER - static var titleWeight: ChatListWeight { Tuner.value("Chat list Title/Weight", default: ChatListWeight.bold) } // LOCAL-TUNER - static var titleColor: Color { Tuner.value("Chat list Title/Color", default: Color.textMain) } // LOCAL-TUNER - - static var previewSize: CGFloat { Tuner.value("Chat list Preview/Size", default: 14, in: 11...18, unit: .points) } // LOCAL-TUNER - static var previewWeight: ChatListWeight { Tuner.value("Chat list Preview/Weight", default: ChatListWeight.regular) } // LOCAL-TUNER - static var previewColor: Color { Tuner.value("Chat list Preview/Color", default: Color.textSecondary) } // LOCAL-TUNER - static var previewTracking: CGFloat { Tuner.value("Chat list Preview/Tracking", default: 0.16, in: -1...2, unit: .points) } // LOCAL-TUNER - static var titleTracking: CGFloat { Tuner.value("Chat list Title/Tracking", default: 0, in: -1...2, unit: .points) } // LOCAL-TUNER - static var dateTracking: CGFloat { Tuner.value("Chat list Date/Tracking", default: 0, in: -1...2, unit: .points) } // LOCAL-TUNER - static var previewLines: Int { Tuner.value("Chat list Preview/Lines", default: 2, in: 1...3) } // LOCAL-TUNER - static var authorWeight: ChatListWeight { Tuner.value("Chat list Preview/Author weight", default: ChatListWeight.bold) } // LOCAL-TUNER - static var authorColor: Color { Tuner.value("Chat list Preview/Author color", default: Color.textSecondary) } // LOCAL-TUNER - - static var dateStyle: ChatListDateStyle { Tuner.value("Chat list Date/Style", default: ChatListDateStyle.shortWeekday) } // LOCAL-TUNER - static var todayStyle: ChatListTodayStyle { Tuner.value("Chat list Date/Today shows", default: ChatListTodayStyle.time) } // LOCAL-TUNER - static var yesterdayWord: Bool { Tuner.value("Chat list Date/Say Yesterday", default: true) } // LOCAL-TUNER - static var dateSize: CGFloat { Tuner.value("Chat list Date/Size", default: 14, in: 10...18, unit: .points) } // LOCAL-TUNER - static var dateWeight: ChatListWeight { Tuner.value("Chat list Date/Weight", default: ChatListWeight.regular) } // LOCAL-TUNER - static var dateColor: Color { Tuner.value("Chat list Date/Color", default: Color.textSecondary) } // LOCAL-TUNER - static var dateTintsUnread: Bool { Tuner.value("Chat list Date/Blue when unread", default: true) } // LOCAL-TUNER - static var dateBaseline: Bool { Tuner.value("Chat list Date/Align to title baseline", default: false) } // LOCAL-TUNER - static var dateGap: CGFloat { Tuner.value("Chat list Date/Gap to badge", default: 6, in: 0...16, unit: .points) } // LOCAL-TUNER - - static var unreadStyle: ChatListUnreadStyle { Tuner.value("Chat list Unread/Style", default: ChatListUnreadStyle.dot) } // LOCAL-TUNER - static var unreadDotSize: CGFloat { Tuner.value("Chat list Unread/Dot size", default: 9, in: 4...16, unit: .points) } // LOCAL-TUNER - static var showsChevron: Bool { Tuner.value("Chat list Row/Show chevron", default: false) } // LOCAL-TUNER - - /// The preview with a leading "Name:" set apart in its own weight and colour. // LOCAL-TUNER - static func styledPreview(_ text: AttributedString) -> AttributedString { // LOCAL-TUNER - let plain = String(text.characters) // LOCAL-TUNER - guard let colon = plain.firstIndex(of: ":"), plain.distance(from: plain.startIndex, to: colon) <= 28, // LOCAL-TUNER - let end = text.characters.index(text.startIndex, offsetBy: plain.distance(from: plain.startIndex, to: colon) + 1, limitedBy: text.endIndex) // LOCAL-TUNER - else { return text } // LOCAL-TUNER - var styled = text // LOCAL-TUNER - styled[text.startIndex.. String { // LOCAL-TUNER - let calendar = Calendar.current // LOCAL-TUNER - if calendar.isDateInToday(date) { // LOCAL-TUNER - switch todayStyle { // LOCAL-TUNER - case .time: return date.formatted(date: .omitted, time: .shortened) // LOCAL-TUNER - case .today: return "Today" // LOCAL-TUNER - case .now: return date.formatted(.relative(presentation: .named, unitsStyle: .narrow)) // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER - if yesterdayWord, calendar.isDateInYesterday(date) { return "Yesterday" } // LOCAL-TUNER - let withinWeek = date > calendar.date(byAdding: .day, value: -6, to: calendar.startOfDay(for: .now))! // LOCAL-TUNER - switch dateStyle { // LOCAL-TUNER - case .relative: return date.formattedRelatively(useTimeForToday: true) // LOCAL-TUNER - case .shortWeekday: return withinWeek ? date.formatted(.dateTime.weekday(.abbreviated)) : date.formatted(.dateTime.month(.abbreviated).day()) // LOCAL-TUNER - case .fullWeekday: return withinWeek ? date.formatted(.dateTime.weekday(.wide)) : date.formatted(.dateTime.month(.abbreviated).day()) // LOCAL-TUNER - case .monthDay: return date.formatted(.dateTime.month(.abbreviated).day()) // LOCAL-TUNER - case .numeric: return date.formatted(.dateTime.month(.defaultDigits).day()) // LOCAL-TUNER - case .ago: return date.formatted(.relative(presentation: .numeric, unitsStyle: .abbreviated)) // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER -} // LOCAL-TUNER - -enum ChatListWeight: String, TunableEnum { // LOCAL-TUNER - case regular, medium, semibold, bold // LOCAL-TUNER - var weight: Font.Weight { switch self { case .regular: .regular; case .medium: .medium; case .semibold: .semibold; case .bold: .bold } } // LOCAL-TUNER -} // LOCAL-TUNER -enum ChatListDateStyle: String, TunableEnum { case relative, shortWeekday, fullWeekday, monthDay, numeric, ago } // LOCAL-TUNER -enum ChatListTodayStyle: String, TunableEnum { case time, today, now } // LOCAL-TUNER -enum ChatListUnreadStyle: String, TunableEnum { case count, dot, none } // LOCAL-TUNER - diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index bc8c3eae1..255ac4523 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -10,12 +10,8 @@ import FlipcashUI /// The Chats tab: the list of tip conversations — tips sent and received. struct TipConversationsScreen: View { - @Tunable("Chat list/Title style") var titleStyle = ChatListTitleStyle.music // LOCAL-TUNER - @Tunable("Chat list/Collapse after", in: 0...120, unit: .points) var collapseDistance: CGFloat = 24 // LOCAL-TUNER - @Tunable("Chat list/Large title size", in: 24...44, unit: .points) var largeTitleSize: CGFloat = 34 // LOCAL-TUNER - @Tunable("Chat list/Large title offset", in: -80...20, unit: .points) var largeTitleOffset: CGFloat = -53 // LOCAL-TUNER - @Tunable("Chat list/Large title leading", in: 0...32, unit: .points) var largeTitleLeading: CGFloat = 16 // LOCAL-TUNER - @State private var titleCollapsed = false // LOCAL-TUNER + /// 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 @@ -108,11 +104,13 @@ struct TipConversationsScreen: View { } .listStyle(.plain) .scrollContentBackground(.hidden) - .onScrollGeometryChange(for: Bool.self) { geometry in // LOCAL-TUNER - geometry.contentOffset.y + geometry.contentInsets.top > (showsChips ? 0 : chipsHeight) + collapseDistance // LOCAL-TUNER - } action: { _, collapsed in // LOCAL-TUNER - withAnimation(.smooth(duration: 0.25)) { titleCollapsed = collapsed } // LOCAL-TUNER - } // LOCAL-TUNER + // 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. @@ -125,36 +123,32 @@ struct TipConversationsScreen: View { } } } - // LOCAL-TUNER: 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) { // LOCAL-TUNER - if titleStyle == .music { // LOCAL-TUNER - Text("Chat") // LOCAL-TUNER - .font(.system(size: largeTitleSize, weight: .bold)) // LOCAL-TUNER - .foregroundStyle(Color.textMain) // LOCAL-TUNER - .fixedSize() // LOCAL-TUNER - .padding(.leading, largeTitleLeading) // LOCAL-TUNER - .offset(y: largeTitleOffset) // LOCAL-TUNER - .opacity(titleCollapsed ? 0 : 1) // LOCAL-TUNER - .allowsHitTesting(false) // LOCAL-TUNER - .accessibilityAddTraits(.isHeader) // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER + // 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) { + 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(titleStyle.mode) // LOCAL-TUNER + .toolbarTitleDisplayMode(.inline) .toolbar { - if titleStyle == .music { // LOCAL-TUNER - ToolbarItem(placement: .principal) { // LOCAL-TUNER - Text("Chat") // LOCAL-TUNER - .font(.headline) // LOCAL-TUNER - .foregroundStyle(Color.textMain) // LOCAL-TUNER - .opacity(titleCollapsed ? 1 : 0) // LOCAL-TUNER - .accessibilityHidden(!titleCollapsed) // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER + ToolbarItem(placement: .principal) { + Text("Chat") + .font(.headline) + .foregroundStyle(Color.textMain) + .opacity(titleCollapsed ? 1 : 0) + .accessibilityHidden(!titleCollapsed) + } ToolbarItem(placement: .topBarTrailing) { NewChatButton() } @@ -385,8 +379,7 @@ struct TipConversationRow: View { RecipientRowAccessory( timestamp: conversation.lastActivity, isUnknown: false, - hasUnread: hasUnread, - unreadCount: unreadCount + hasUnread: hasUnread ) } .accessibilityIdentifier(chatTypeIdentifier) @@ -435,7 +428,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" @@ -618,8 +611,3 @@ private struct FadingChips: View { content.opacity(isShown ? 1 : fade.progress) } } - -enum ChatListTitleStyle: String, TunableEnum { // LOCAL-TUNER - case music, large, inlineLarge, inline // LOCAL-TUNER - var mode: ToolbarTitleDisplayMode { switch self { case .music, .inline: .inline; case .large: .large; case .inlineLarge: .inlineLarge } } // LOCAL-TUNER -} // LOCAL-TUNER diff --git a/Flipcash/Core/Screens/Profile/E2eeLearnMoreSheet.swift b/Flipcash/Core/Screens/Profile/E2eeLearnMoreSheet.swift index 2fa06f5b7..a4e7638af 100644 --- a/Flipcash/Core/Screens/Profile/E2eeLearnMoreSheet.swift +++ b/Flipcash/Core/Screens/Profile/E2eeLearnMoreSheet.swift @@ -26,7 +26,7 @@ struct E2eeLearnMoreSheet: View { var body: some View { Tray(title: title, message: message, icon: icon, actions: actions) { - VStack(spacing: E2eeTrayLook.sectionGap) { + VStack(spacing: 12) { section("ENCRYPTED", items: encrypted, isEncrypted: true) section("NOT ENCRYPTED", items: notEncrypted, isEncrypted: false) } @@ -44,12 +44,11 @@ struct E2eeLearnMoreSheet: View { } private var actions: [TrayAction] { - E2eeTrayLook.showsCTA ? [TrayAction("Got It") { isPresented = false }] : [] + [TrayAction("Got It") { isPresented = false }] } - @ViewBuilder private func section(_ heading: String, items: [String], isEncrypted: Bool) -> some View { - let list = VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { Text(heading) .font(.system(size: 11, weight: .bold)) .tracking(0.44) @@ -67,18 +66,8 @@ struct E2eeLearnMoreSheet: View { } } .frame(maxWidth: .infinity, alignment: .leading) - - switch E2eeTrayLook.sectionStyle { - case .card: - list - .padding(16) - .background( - Color.white.opacity(E2eeTrayLook.sectionFillOpacity), - in: .rect(cornerRadius: E2eeTrayLook.sectionRadius, style: .continuous) - ) - case .plain: - list - } + .padding(16) + .background(Color.white.opacity(0.05), in: .rect(cornerRadius: 16, style: .continuous)) } private func bullet(isEncrypted: Bool) -> some View { @@ -189,16 +178,3 @@ struct E2eeFooter: View { } } } - -/// The E2EE tray's options while the look is tuned. // LOCAL-TUNER -@MainActor // LOCAL-TUNER -enum E2eeTrayLook { // LOCAL-TUNER - static var showsCTA: Bool { Tuner.value("Tray E2EE/Got It button", default: true) } // LOCAL-TUNER - static var sectionStyle: E2eeSectionStyle { Tuner.value("Tray E2EE/Section style", default: E2eeSectionStyle.card) } // LOCAL-TUNER - static var sectionRadius: CGFloat { Tuner.value("Tray E2EE/Section radius", default: 16, in: 0...32, unit: .points) } // LOCAL-TUNER - static var sectionFillOpacity: Double { Tuner.value("Tray E2EE/Section fill opacity", default: 0.05, in: 0...0.2) } // LOCAL-TUNER - static var sectionGap: CGFloat { Tuner.value("Tray E2EE/Section gap", default: 12, in: 0...32, unit: .points) } // LOCAL-TUNER -} // LOCAL-TUNER - -/// Whether each E2EE list sits on a card. // LOCAL-TUNER -enum E2eeSectionStyle: TunableEnum { case card, plain } // LOCAL-TUNER diff --git a/Flipcash/Core/Screens/Profile/MuteChatSheet.swift b/Flipcash/Core/Screens/Profile/MuteChatSheet.swift index 8dcd8b5c6..6b30c4768 100644 --- a/Flipcash/Core/Screens/Profile/MuteChatSheet.swift +++ b/Flipcash/Core/Screens/Profile/MuteChatSheet.swift @@ -88,13 +88,10 @@ struct MuteChatSheet: View { TrayOptionRow( row.title, icon: icon(for: row.option), - isSelected: pattern == .selectThenConfirm && selection == row.option + isSelected: selection == row.option ) { guard !isWorking else { return } - switch pattern { - case .tapToApply: Task { await apply(row.option) } - case .selectThenConfirm: selection = row.option - } + selection = row.option } .accessibilityIdentifier("mute-chat-\(identifier(for: row.option))") } @@ -102,26 +99,18 @@ struct MuteChatSheet: View { } } - /// Whether a row applies itself or waits for the CTA; the tuner's "Tray Mute/Pattern". // LOCAL-TUNER - private var pattern: MuteTrayPattern { Tuner.value("Tray Mute/Pattern", default: MuteTrayPattern.selectThenConfirm) } // LOCAL-TUNER - /// The CTA that applies the selected row, worded for an unmute when that is the pick. private var actions: [TrayAction] { - switch pattern { - case .tapToApply: - return [] - case .selectThenConfirm: - return [ - TrayAction( - selection == .never ? "Unmute" : "Mute", - state: isWorking ? .loading : .normal, - isEnabled: selection != nil - ) { - guard let selection else { return } - Task { await apply(selection) } - }, - ] - } + [ + 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) -> Icon { @@ -182,11 +171,3 @@ struct MuteChatSheet: View { } } } - -/// How the mute tray's rows act. // LOCAL-TUNER -enum MuteTrayPattern: TunableEnum { // LOCAL-TUNER - /// A tap mutes at once, with no CTA. // LOCAL-TUNER - case tapToApply // LOCAL-TUNER - /// A tap selects; the CTA mutes. // LOCAL-TUNER - case selectThenConfirm // LOCAL-TUNER -} // LOCAL-TUNER diff --git a/Flipcash/Core/Screens/Profile/PublicGroupLook.swift b/Flipcash/Core/Screens/Profile/PublicGroupLook.swift index e558647ef..e1bb475f2 100644 --- a/Flipcash/Core/Screens/Profile/PublicGroupLook.swift +++ b/Flipcash/Core/Screens/Profile/PublicGroupLook.swift @@ -7,80 +7,80 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// The tuned numbers of the public group profile's cover-colored page. +/// 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 var saturation: Double { Tuner.shared.value("Profile/Public/Saturation", default: 1.2, in: 0...2, step: 0.01, unit: .multiplier) } // LOCAL-TUNER + static let saturation: Double = 1.2 /// The OKLab lightness the page is pulled toward. - static var lightnessDark: Double { Tuner.shared.value("Profile/Public/L dark", default: 0.1, in: 0...0.4, step: 0.01) } // LOCAL-TUNER + static let lightnessDark: Double = 0.1 /// How far toward ``lightnessDark`` the cover's color moves. - static var lightnessPull: Double { Tuner.shared.value("Profile/Public/Pull", default: 0.6, in: 0...1, step: 0.01) } // LOCAL-TUNER + static let lightnessPull: Double = 0.6 /// The length of the melt from the cover into the page. - static var fadeHeight: CGFloat { Tuner.shared.value("Profile/Public/Fade height", default: CGFloat(214), in: 0...320, step: 1, unit: .points) } // LOCAL-TUNER + static let fadeHeight: CGFloat = 214 /// The cover's height from the top of the screen, under the status and navigation bars. - static var coverHeight: CGFloat { Tuner.shared.value("Profile/Public/Cover height", default: CGFloat(320), in: 160...420, step: 1, unit: .points) } // LOCAL-TUNER + 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 var contentOffset: CGFloat { Tuner.shared.value("Profile/Public/Content offset", default: CGFloat(4), in: -40...160, step: 1, unit: .points) } // LOCAL-TUNER + static let contentOffset: CGFloat = 4 /// Whether the identity's avatar is drawn inside a ring of the page's color. - static var avatarStroke: Bool { Tuner.shared.value("Profile/Public/Avatar stroke", default: true) } // LOCAL-TUNER + static let avatarStroke: Bool = true /// The width of that ring. - static var avatarStrokeWidth: CGFloat { Tuner.shared.value("Profile/Public/Avatar stroke width", default: CGFloat(3), in: 0...8, step: 0.5, unit: .points) } // LOCAL-TUNER + static let avatarStrokeWidth: CGFloat = 3 // MARK: Boxes and spacing /// The corner radius of every box: the requirement tiles and the pulse card. - static var cardRadius: CGFloat { Tuner.shared.value("Profile/Public/Card radius", default: CGFloat(24), in: 6...32, step: 1, unit: .points) } // LOCAL-TUNER + static let cardRadius: CGFloat = 24 /// The padding inside every box. - static var cardPadding: CGFloat { Tuner.shared.value("Profile/Public/Card padding", default: CGFloat(16), in: 8...28, step: 1, unit: .points) } // LOCAL-TUNER + static let cardPadding: CGFloat = 16 /// The space above and below the hairline in a box, between its summary and the row under it. - static var dividerGap: CGFloat { Tuner.shared.value("Profile/Public/Divider gap", default: CGFloat(12), in: 4...24, step: 1, unit: .points) } // LOCAL-TUNER + static let dividerGap: CGFloat = 12 /// The gap from the pulse card's facepile to its headline. - static var facepileToHeadline: CGFloat { Tuner.shared.value("Profile/Public/Facepile to headline", default: CGFloat(12), in: 4...24, step: 1, unit: .points) } // LOCAL-TUNER + static let facepileToHeadline: CGFloat = 12 /// The gap from the pulse card's headline to its meta line. - static var headlineToMeta: CGFloat { Tuner.shared.value("Profile/Public/Headline to meta", default: CGFloat(2), in: 0...12, step: 1, unit: .points) } // LOCAL-TUNER + static let headlineToMeta: CGFloat = 2 /// The gap between boxes, stacked and side by side. - static var cardGap: CGFloat { Tuner.shared.value("Profile/Public/Card gap", default: CGFloat(12), in: 0...32, step: 1, unit: .points) } // LOCAL-TUNER + static let cardGap: CGFloat = 12 /// The gap above the closing action under the boxes, such as Leave Chat. - static var leaveGap: CGFloat { Tuner.shared.value("Profile/Public/Leave gap", default: CGFloat(12), in: 0...48, step: 1, unit: .points) } // LOCAL-TUNER + static let leaveGap: CGFloat = 12 /// Whether Leave Chat's label is the white control label rather than the quieter secondary tint. - static var leaveLabelIsWhite: Bool { Tuner.shared.value("Profile/Public/Leave label white", default: false) } // LOCAL-TUNER + static let leaveLabelIsWhite: Bool = false /// The gap from the last element of the identity to the first box. - static var identityToCards: CGFloat { Tuner.shared.value("Profile/Public/Identity to cards", default: CGFloat(40), in: 0...56, step: 1, unit: .points) } // LOCAL-TUNER + static let identityToCards: CGFloat = 40 /// The margin the page's content sits inside, on each side. - static var sideMargin: CGFloat { Tuner.shared.value("Profile/Public/Side margin", default: CGFloat(20), in: 12...32, step: 1, unit: .points) } // LOCAL-TUNER + static let sideMargin: CGFloat = 20 /// The gap from the avatar to the name. - static var avatarToName: CGFloat { Tuner.shared.value("Profile/Public/Avatar to name", default: CGFloat(12), in: 0...32, step: 1, unit: .points) } // LOCAL-TUNER + static let avatarToName: CGFloat = 12 /// The gap from the name to the member count. - static var nameToMeta: CGFloat { Tuner.shared.value("Profile/Public/Name to meta", default: CGFloat(4), in: 0...16, step: 1, unit: .points) } // LOCAL-TUNER + static let nameToMeta: CGFloat = 4 /// The gap from the member count to the bio. - static var metaToBio: CGFloat { Tuner.shared.value("Profile/Public/Meta to bio", default: CGFloat(8), in: 0...24, step: 1, unit: .points) } // LOCAL-TUNER + static let metaToBio: CGFloat = 8 /// The gap from the last text of the identity to the Share and Edit pills. - static var bioToActions: CGFloat { Tuner.shared.value("Profile/Public/Bio to actions", default: CGFloat(16), in: 0...32, step: 1, unit: .points) } // LOCAL-TUNER + static let bioToActions: CGFloat = 16 /// The corner radius of the Share and Edit pills; half their height is a capsule. - static var pillRadius: CGFloat { Tuner.shared.value("Profile/Public/Pill radius", default: CGFloat(22), in: 6...22, step: 1, unit: .points) } // LOCAL-TUNER + static let pillRadius: CGFloat = 22 /// The outline every box is drawn with. static var cardShape: RoundedRectangle { diff --git a/Flipcash/Core/Screens/Profile/PublicGroupProfilePlayground.swift b/Flipcash/Core/Screens/Profile/PublicGroupProfilePlayground.swift deleted file mode 100644 index 680f7629e..000000000 --- a/Flipcash/Core/Screens/Profile/PublicGroupProfilePlayground.swift +++ /dev/null @@ -1,308 +0,0 @@ -// -// PublicGroupProfilePlayground.swift -// Flipcash -// - -#if DEBUG // LOCAL-TUNER: the public group profile playground, a local feel tool -import SwiftUI -import FlipcashCore -import FlipcashUI - -/// The Beta Features entry that opens the public group profile playground. -struct PublicGroupProfilePlaygroundEntry: View { - - var body: some View { - VStack(spacing: 0) { - SettingsSectionHeader("Profile Playground") - NavigationLink { - PublicGroupProfilePlayground() - } label: { - PlaygroundRow(title: "Public group profile playground") - } - .buttonStyle(.plain) - .accessibilityIdentifier("public-group-profile-playground") - } - } -} - -/// Every state of the public group profile, from sample data with no network. Each one pushes the -/// full screen, whose ⋯ menu swaps the state in place so the tuner panel can stay open. No control -/// on the profile does anything. -struct PublicGroupProfilePlayground: View { - - var body: some View { - Background(color: .backgroundMain) { - ScrollView { - LazyVStack(spacing: 0) { - ForEach(PublicGroupProfilePlaygroundState.allCases) { state in - NavigationLink { - PublicGroupProfilePlaygroundPage(state: state) - } label: { - PlaygroundRow(title: state.title) - } - .buttonStyle(.plain) - .accessibilityIdentifier("profile-playground-\(state.rawValue)") - } - } - } - } - .navigationTitle("Profile Playground") - .toolbarTitleDisplayMode(.inline) - } -} - -/// One state of the profile, drawn full screen. -struct PublicGroupProfilePlaygroundPage: View { - - @State var state: PublicGroupProfilePlaygroundState - - var body: some View { - let sample = state.sample - PublicGroupProfileView( - content: sample.content, - hasPinned: true, - portraitData: { _ in nil }, - loadPortrait: { _ in }, - onSelectPerson: { _ in }, - onToken: { _ in }, - accessory: { EmptyView() }, - actions: { - if sample.isHost { - Button("Edit Group") {} - .buttonStyle(PublicGroupOutlineButtonStyle()) - } - Button("Share") {} - .buttonStyle(PublicGroupOutlineButtonStyle()) - }, - hasFooter: sample.isMember, - footer: { - if sample.isMember { - Button {} label: { - ButtonStateLabel("Leave Chat", state: .normal) - } - .buttonStyle(PublicGroupClosingButtonStyle()) - } - }, - pinned: { - Button("Open Chat") {} - .buttonStyle(PublicGroupCapsuleButtonStyle()) - .padding(.horizontal, PublicGroupCapsuleButtonStyle.sideInset) - .padding(.top, 12) - .padding(.bottom, 8) - } - ) - .bleedPalette(key: sample.bleedKey, image: sample.bleedImage, settings: PublicGroupLook.settings) - // A fresh page per state, so the palette the last one held doesn't color one with no image. - .id(state) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Menu { - Picker("State", selection: $state) { - ForEach(PublicGroupProfilePlaygroundState.allCases) { state in - Text(state.title).tag(state) - } - } - } label: { - Image.system(.ellipsis) - } - .accessibilityIdentifier("profile-playground-states") - } - } - } -} - -/// A plain Beta Features row: a title and a chevron over a hairline. -private struct PlaygroundRow: View { - - let title: String - - var body: some View { - HStack { - Text(title) - .font(.appTextMedium) - .foregroundStyle(Color.textMain) - .padding(.vertical, 10) - Spacer() - Image.icon(.chevronRightMedium) - .iconSize(16) - .foregroundStyle(Color.textSecondary) - } - .padding(.horizontal, 20) - .padding(.vertical, 10) - .vSeparator(color: .rowSeparator, position: .bottom) - .contentShape(Rectangle()) - } -} - -// MARK: - States - - -enum PublicGroupProfilePlaygroundState: String, CaseIterable, Identifiable, Hashable { - case standard - case busy - case quiet - case nobody - case split - case noCover - case noPicture - case member - case nonMember - case host - case longTitle - case loading - - var id: String { rawValue } - - var title: String { - switch self { - case .standard: "Default: cover, 6 chatting" - case .busy: "Busy: 84, +79, long bio" - case .quiet: "Quiet: 1 chatting" - case .nobody: "Nobody this week" - case .split: "Join ≠ chat (split tiles)" - case .noCover: "No cover (avatar colour)" - case .noPicture: "No avatar or picture" - case .member: "Member (Leave)" - case .nonMember: "Non-member (no Leave)" - case .host: "Host (Edit Group)" - case .longTitle: "Long title" - case .loading: "Requirements loading" - } - } - - /// The sample this state draws. - @MainActor - var sample: PublicGroupProfilePlaygroundSample { - var sample = PublicGroupProfilePlaygroundSample(id: rawValue) - switch self { - case .standard, .member: - break - case .busy: - sample.cover = .warm - sample.chatting = 84 - sample.memberCount = 1_284 - sample.description = Self.longBio - case .quiet: - sample.cover = .navy - sample.chatting = 1 - sample.memberCount = 3 - case .nobody: - sample.cover = .green - sample.chatting = 0 - case .split: - sample.split = true - case .noCover: - sample.cover = nil - sample.avatar = .warm - case .noPicture: - sample.cover = nil - sample.avatar = nil - sample.avatarBlurhash = nil - case .nonMember: - sample.isMember = false - case .host: - sample.isHost = true - case .longTitle: - sample.title = "The Extremely Long Named Flipcash Product Feedback and Bug Reports Group" - case .loading: - sample.isLoading = true - } - return sample - } - - /// 160 characters. - private static let longBio = "Weekly drops, honest feedback and the odd meme. Post bugs, ideas and wins here; the team reads everything and replies fast. Be kind, stay on topic and have fun." -} - -/// What one playground state draws, built up from the default. -struct PublicGroupProfilePlaygroundSample { - - /// A generated image the page can be colored from. - enum Swatch: String { - case blurhash, navy, warm, green - - @MainActor - var image: UIImage? { - switch self { - case .blurhash: BlurHashCache.shared.image(for: PublicGroupProfilePlaygroundSample.blurhash, width: 32, height: 32) - case .navy: Self.navyImage - case .warm: Self.warmImage - case .green: Self.greenImage - } - } - - private static let navyImage = gradient(UIColor(red: 0.08, green: 0.12, blue: 0.36, alpha: 1), UIColor(red: 0.20, green: 0.32, blue: 0.70, alpha: 1)) - private static let warmImage = gradient(UIColor(red: 0.85, green: 0.38, blue: 0.16, alpha: 1), UIColor(red: 0.95, green: 0.72, blue: 0.30, alpha: 1)) - private static let greenImage = gradient(UIColor(red: 0.06, green: 0.36, blue: 0.22, alpha: 1), UIColor(red: 0.42, green: 0.74, blue: 0.40, alpha: 1)) - - private static func gradient(_ from: UIColor, _ to: UIColor) -> UIImage { - let size = CGSize(width: 64, height: 64) - return UIGraphicsImageRenderer(size: size).image { context in - let colors = [from.cgColor, to.cgColor] as CFArray - guard let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: colors, locations: [0, 1]) else { return } - context.cgContext.drawLinearGradient(gradient, start: .zero, end: CGPoint(x: size.width, y: size.height), options: []) - } - } - } - - static let blurhash = "LGF5]+Yk^6#M@-5c,1J5@[or[Q6." - - let id: String - var title = "Flipcash Product Feedback" - var memberCount: UInt64 = 22 - var description = "Something look off? Not work? Crash? Post it here for the team to be aware." - var cover: Swatch? = .blurhash - var avatar: Swatch? - var avatarBlurhash: String? = Self.blurhash - var chatting = 6 - var split = false - var isLoading = false - var isMember = true - var isHost = false - - /// The image the page is colored from: the cover, else the avatar, else none. - @MainActor - var bleedImage: UIImage? { (cover ?? avatar)?.image } - - /// Identifies ``bleedImage`` for the color cache. - var bleedKey: String? { (cover ?? avatar).map { "playground-\($0.rawValue)" } } - - @MainActor - var content: 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 count = max(chatting, 1) - let chatters = (0.. 0 else { return false // No minimum requirement or server didn't provide one } diff --git a/Flipcash/UI/EnterAmountView.swift b/Flipcash/UI/EnterAmountView.swift index 6add4a009..f20121fb6 100644 --- a/Flipcash/UI/EnterAmountView.swift +++ b/Flipcash/UI/EnterAmountView.swift @@ -142,7 +142,6 @@ public struct EnterAmountView: View { // absorbs the rest on taller devices. Spacer().frame(maxHeight: 24) header - .padding(.leading, Tuner.shared.value("Amount entry/Amount left inset", default: CGFloat(20), in: 0...48, unit: .points) - 20) // LOCAL-TUNER } else { Spacer() @@ -234,7 +233,7 @@ public struct EnterAmountView: View { .disabled(!actionEnabled(enteredAmount) || !isActionIdle) // Amount entry v2: 32 from the screen's sides and bottom (the screens already pad 20), // 22 below the keypad. - .padding(.top, Tuner.shared.value("Amount entry/Keypad to button", default: CGFloat(20), in: 0...48, unit: .points)) // LOCAL-TUNER + .padding(.top, 20) .modifier(ActionSlotInsets()) } } @@ -260,8 +259,8 @@ private enum SwipeDim { private struct ActionSlotInsets: ViewModifier { func body(content: Content) -> some View { content - .padding(.horizontal, Tuner.shared.value("Amount entry/Button side inset", default: CGFloat(32), in: 16...56, unit: .points) - 20) // LOCAL-TUNER - .padding(.bottom, Tuner.shared.value("Amount entry/Button bottom inset", default: CGFloat(32), in: 0...80, unit: .points) - 54) // LOCAL-TUNER + .padding(.horizontal, 32 - 20) + .padding(.bottom, 32 - 54) } } @@ -353,14 +352,13 @@ private struct AmountActionButtonStyle: ButtonStyle { @Environment(\.isEnabled) private var isEnabled func makeBody(configuration: Configuration) -> some View { - let height = Tuner.shared.value("Amount entry/Button height", default: CGFloat(54), in: 40...64, unit: .points) // LOCAL-TUNER - return configuration.label + configuration.label .font(.appTextMedium) .foregroundStyle(isEnabled ? Color.textAction : Color.textMain.opacity(0.2)) .frame(maxWidth: .infinity) - .frame(height: height) + .frame(height: 54) .background { - RoundedRectangle(cornerRadius: Tuner.shared.value("Amount entry/Button radius", default: CGFloat(27), in: 0...32, unit: .points), style: .continuous) // LOCAL-TUNER + RoundedRectangle(cornerRadius: 27, style: .continuous) .fill(isEnabled ? Color.action : Color.action.opacity(0.1)) } .opacity(configuration.isPressed ? 0.7 : 1.0) diff --git a/Flipcash/UI/View+Dialog.swift b/Flipcash/UI/View+Dialog.swift index 6b1bae851..3c6383367 100644 --- a/Flipcash/UI/View+Dialog.swift +++ b/Flipcash/UI/View+Dialog.swift @@ -7,36 +7,26 @@ import SwiftUI import FlipcashUI extension View { - /// Presents a `DialogItem`, reporting an analytics event for tracked items. - @ViewBuilder // LOCAL-TUNER + /// Presents a `DialogItem` as a tray, reporting an analytics event for tracked items. func dialog(item: Binding) -> some View { - if DialogTrayModifier.isOn { modifier(DialogTrayModifier(item: item)) } else { // LOCAL-TUNER - modifier(DialogItemModifier(item: item)) - } // LOCAL-TUNER + 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, @@ -49,7 +39,6 @@ private struct DialogItemModifier: ViewModifier { callSite: nil ) } - } } } @@ -63,126 +52,84 @@ private struct DialogItemModifier: ViewModifier { } } -/// Presents a `DialogItem` as a tray: the same title, subtitle, style, dismissability, actions, // LOCAL-TUNER -/// analytics and `onDismiss` as the dialog, with an icon for the error and success styles. // LOCAL-TUNER -private struct DialogTrayModifier: ViewModifier { // LOCAL-TUNER - - /// Whether dialogs show as trays; the tuner's "Tray Behavior/Dialogs as trays". // LOCAL-TUNER - @MainActor static var isOn: Bool { Tuner.shared.value("Tray Behavior/Dialogs as trays", default: true) } // LOCAL-TUNER - - let item: Binding // LOCAL-TUNER - @Environment(AppRouter.self) private var router: AppRouter? // LOCAL-TUNER - - /// Captured while the tray is up so its `onDismiss` still fires when a drag clears the binding. // LOCAL-TUNER - @State private var pendingDismiss: (() -> Void)? // LOCAL-TUNER - - func body(content: Content) -> some View { // LOCAL-TUNER - content // LOCAL-TUNER - .tray(item: item, isDismissable: item.wrappedValue?.dismissable ?? true, onDismiss: runPendingDismiss) { presented in // LOCAL-TUNER - DialogTray(item: presented, dismiss: dismiss) // LOCAL-TUNER - .onAppear { // LOCAL-TUNER - pendingDismiss = presented.onDismiss // LOCAL-TUNER - guard presented.tracked, // LOCAL-TUNER - let title = presented.title, // LOCAL-TUNER - let subtitle = presented.subtitle else { return } // LOCAL-TUNER - Analytics.errorModalDisplayed( // LOCAL-TUNER - title: title, // LOCAL-TUNER - message: subtitle, // LOCAL-TUNER - screen: router?.presentedSheet?.description ?? "scan", // LOCAL-TUNER - callSite: nil // LOCAL-TUNER - ) // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER - - private func dismiss() { // LOCAL-TUNER - item.wrappedValue = nil // LOCAL-TUNER - } // LOCAL-TUNER - - private func runPendingDismiss() { // LOCAL-TUNER - pendingDismiss?() // LOCAL-TUNER - pendingDismiss = nil // LOCAL-TUNER - } // LOCAL-TUNER -} // LOCAL-TUNER - -/// A `DialogItem` drawn as a single-step tray, its checkbox as the tray's content. // LOCAL-TUNER -private struct DialogTray: View { // LOCAL-TUNER - - let item: DialogItem // LOCAL-TUNER - let dismiss: () -> Void // LOCAL-TUNER - - @State private var isChecked = false // LOCAL-TUNER - - var body: some View { // LOCAL-TUNER - if let checkbox = item.checkbox { // LOCAL-TUNER - Tray(title: item.title ?? "", message: item.subtitle, icon: icon, style: style, actions: actions) { // LOCAL-TUNER - checkboxRow(checkbox) // LOCAL-TUNER - } // LOCAL-TUNER - } else { // LOCAL-TUNER - Tray(title: item.title ?? "", message: item.subtitle, icon: icon, style: style, actions: actions) // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER - - private var style: TrayStyle { // LOCAL-TUNER - switch item.style { // LOCAL-TUNER - case .standard: .standard // LOCAL-TUNER - case .success: .success // LOCAL-TUNER - case .destructive: .error // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER - - private var icon: TrayIcon? { // LOCAL-TUNER - switch item.style { // LOCAL-TUNER - case .standard: nil // LOCAL-TUNER - case .success: .central(.circleCheck) // LOCAL-TUNER - case .destructive: .destructive // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER - - /// The dialog's actions as tray actions, each dismissing before it runs as the dialog's do. // LOCAL-TUNER - /// A second action that would share the first one's place takes the secondary's. // LOCAL-TUNER - private var actions: [TrayAction] { // LOCAL-TUNER - var taken: Set = [] // LOCAL-TUNER - return item.actions.map { action in // LOCAL-TUNER - // A lone destructive action is the red dialog's OK, not a warning. // LOCAL-TUNER - var role = item.actions.count == 1 ? .primary : Self.role(for: action.kind) // LOCAL-TUNER - let slot = role == .secondary ? 0 : 1 // LOCAL-TUNER - if taken.contains(slot) { // LOCAL-TUNER - role = slot == 0 ? .primary : .secondary // LOCAL-TUNER - } // LOCAL-TUNER - taken.insert(role == .secondary ? 0 : 1) // LOCAL-TUNER - return TrayAction(action.title, role: role) { // LOCAL-TUNER - dismiss() // LOCAL-TUNER - action.perform(isChecked: isChecked) // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER - - private static func role(for kind: DialogAction.Kind) -> TrayAction.Role { // LOCAL-TUNER - switch kind { // LOCAL-TUNER - case .standard: .primary // LOCAL-TUNER - case .destructive: .destructive // LOCAL-TUNER - case .subtle, .outline: .secondary // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER - - private func checkboxRow(_ checkbox: DialogCheckbox) -> some View { // LOCAL-TUNER - Button { // LOCAL-TUNER - isChecked.toggle() // LOCAL-TUNER - } label: { // LOCAL-TUNER - HStack(spacing: 10) { // LOCAL-TUNER - Image(systemName: isChecked ? "checkmark.square.fill" : "square") // LOCAL-TUNER - .font(.system(size: 20)) // LOCAL-TUNER - Text(checkbox.label) // LOCAL-TUNER - .font(.default(size: 14, weight: .medium)) // LOCAL-TUNER - .frame(maxWidth: .infinity, alignment: .leading) // LOCAL-TUNER - } // LOCAL-TUNER - .contentShape(Rectangle()) // LOCAL-TUNER - .padding(.vertical, 8) // LOCAL-TUNER - } // LOCAL-TUNER - .buttonStyle(.plain) // LOCAL-TUNER - .foregroundStyle(Color.textMain) // LOCAL-TUNER - .accessibilityAddTraits(isChecked ? .isSelected : []) // LOCAL-TUNER - .accessibilityIdentifier("dialog-checkbox") // LOCAL-TUNER - } // LOCAL-TUNER -} // LOCAL-TUNER +/// 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) + } + .contentShape(Rectangle()) + .padding(.vertical, 8) + } + .buttonStyle(.plain) + .foregroundStyle(Color.textMain) + .accessibilityAddTraits(isChecked ? .isSelected : []) + .accessibilityIdentifier("dialog-checkbox") + } +} diff --git a/FlipcashUI/Package.swift b/FlipcashUI/Package.swift index bb92361d8..b72ace312 100644 --- a/FlipcashUI/Package.swift +++ b/FlipcashUI/Package.swift @@ -26,7 +26,7 @@ let sharedCore: Package.Dependency = { let package = Package( name: "FlipcashUI", platforms: [ - .iOS(.v26), // LOCAL-TUNER + .iOS(.v18), ], products: [ .library( @@ -35,7 +35,6 @@ let package = Package( ), ], dependencies: [ - .package(path: "../Packages/TunerKit"), // LOCAL-TUNER .package(path: "../FlipcashCore"), .package(url: "https://github.com/ekazaev/ChatLayout", from: "2.4.2"), .package(url: "https://github.com/ra1028/DifferenceKit", from: "1.3.0"), @@ -46,7 +45,6 @@ let package = Package( .target( name: "FlipcashUI", dependencies: [ - .product(name: "TunerKit", package: "TunerKit"), // LOCAL-TUNER .product(name: "FlipcashCore", package: "FlipcashCore"), .product(name: "ChatLayout", package: "ChatLayout", condition: .when(platforms: [.iOS])), .product(name: "DifferenceKit", package: "DifferenceKit"), diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift index 618e1deb3..aade09d0d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift @@ -239,9 +239,9 @@ final class BubbleBackgroundView: UIView { /// flat against the transcript, measured at rgb 17 right up to its edge on all sides. Owning the /// values here also means the menu's lift and the edit that follows it share one shadow rather /// than one of them guessing at a system default the other inherited. - private static var liftShadowOpacity: Float { Float(Tuner.value("Chat v2 Lift/Shadow opacity", default: 0.135, in: 0...0.6, unit: .percent)) } // LOCAL-TUNER - private static var liftShadowRadius: CGFloat { Tuner.value("Chat v2 Lift/Shadow blur", default: 20, in: 0...60, unit: .points) } // LOCAL-TUNER - private static var liftShadowOffset: CGSize { CGSize(width: 0, height: Tuner.value("Chat v2 Lift/Shadow offset Y", default: 10, in: -20...40, unit: .points)) } // LOCAL-TUNER + private static let liftShadowOpacity: Float = 0.135 + private static let liftShadowRadius: CGFloat = 20 + private static let liftShadowOffset = CGSize(width: 0, height: 10) /// Raises `view` to the lifted plane. `shape` is the bubble's own path, so the shadow follows a /// flattened grouped corner instead of falling back to the view's square bounds. `nil` — a bare diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatAuthorAvatarView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatAuthorAvatarView.swift index fdc770951..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,19 +19,20 @@ 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 var size: CGFloat { ChatStyle.authorAvatarSize } // LOCAL-TUNER + /// 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() private let gradient = CAGradientLayer() - private lazy var sizeConstraints = [widthAnchor.constraint(equalToConstant: Self.size), heightAnchor.constraint(equalToConstant: Self.size)] // LOCAL-TUNER override init(frame: CGRect) { super.init(frame: frame) 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. @@ -56,8 +57,8 @@ final class ChatAuthorAvatarView: UIView { addSubview(imageView) NSLayoutConstraint.activate([ - sizeConstraints[0], // LOCAL-TUNER - sizeConstraints[1], // LOCAL-TUNER + widthAnchor.constraint(equalToConstant: Self.size), + heightAnchor.constraint(equalToConstant: Self.size), monogram.centerXAnchor.constraint(equalTo: centerXAnchor), monogram.centerYAnchor.constraint(equalTo: centerYAnchor), imageView.leadingAnchor.constraint(equalTo: leadingAnchor), @@ -78,13 +79,11 @@ final class ChatAuthorAvatarView: UIView { CATransaction.setDisableActions(true) gradient.frame = bounds CATransaction.commit() - applyTuning() // LOCAL-TUNER } /// Draws `author`, using `imageData` when the bytes have arrived. Safe to call on every /// reconfigure — a repeat with the same inputs reads the decoded image straight out of the cache. func configure(with author: ChatAuthor, imageData: Data?) { - applyTuning() // LOCAL-TUNER let key = author.id.uuidString if let imageData, let image = ContactAvatarCache.shared.image(forKey: key, data: imageData) { imageView.image = image @@ -96,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 @@ -104,16 +103,6 @@ final class ChatAuthorAvatarView: UIView { accessibilityLabel = author.name.isEmpty ? "Contact" : author.name } - // LOCAL-TUNER: size, rounding, monogram and ring from the tuner, so a reload restyles a reused view. - func applyTuning() { // LOCAL-TUNER - let size = Self.size // LOCAL-TUNER - sizeConstraints.forEach { $0.constant = size } // LOCAL-TUNER - layer.cornerRadius = size / 2 // LOCAL-TUNER - monogram.font = .default(size: size * 16 / 44, weight: .bold) // LOCAL-TUNER - layer.borderWidth = ChatStyle.avatarStrokeWidth // LOCAL-TUNER - layer.borderColor = UIColor.white.withAlphaComponent(ChatStyle.avatarStrokeOpacity).cgColor // LOCAL-TUNER - } // LOCAL-TUNER - /// Clears the image so a recycled row never shows the previous author's face. func reset() { imageView.image = nil diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdrop.swift index e5fd21d65..0f1859412 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdrop.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdrop.swift @@ -24,7 +24,7 @@ enum ChatBackdrop { /// 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 var blurFraction: CGFloat { Tuner.value("Chat v2 Lift/Blur strength", default: 0.1, in: 0...1, unit: .percent) } // LOCAL-TUNER + static let blurFraction: CGFloat = 0.1 /// The black that goes with the blur wherever nothing else is darkening the screen. /// @@ -35,7 +35,7 @@ enum ChatBackdrop { /// 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 var dimAlpha: CGFloat { Tuner.value("Chat v2 Lift/Dim", default: 0.33, in: 0...0.9, unit: .percent) } // LOCAL-TUNER + static let dimAlpha: CGFloat = 0.33 /// Applies the material to `view` at ``blurFraction``, and hands back the animator holding it /// there. @@ -45,17 +45,10 @@ enum ChatBackdrop { /// 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 { - let material = UIBlurEffect(style: Tuner.value("Chat v2 Lift/Material", default: ChatBlurMaterial.ultraThin).style) // LOCAL-TUNER + let material = UIBlurEffect(style: .systemUltraThinMaterialDark) let strength = UIViewPropertyAnimator(duration: 1, curve: .linear) { view.effect = material } strength.fractionComplete = blurFraction return strength } } - -nonisolated enum ChatBlurMaterial: String, TunableEnum, Sendable { // LOCAL-TUNER - case ultraThin, thin, regular, thick, chrome // LOCAL-TUNER - var style: UIBlurEffect.Style { // LOCAL-TUNER - switch self { case .ultraThin: .systemUltraThinMaterialDark; case .thin: .systemThinMaterialDark; case .regular: .systemMaterialDark; case .thick: .systemThickMaterialDark; case .chrome: .systemChromeMaterialDark } // LOCAL-TUNER - } // LOCAL-TUNER -} // LOCAL-TUNER #endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift index e2dd3b34d..e47033450 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift @@ -387,7 +387,6 @@ public final class ChatBubbleView: UIView { if !message.rendersAsLargeEmoji { attributes.merge(ChatStyle.bodyLineAttributes(for: bodyFont)) { _, new in new } } - if isFromSelf, !isPlaceholder, let glow = ChatStyle.sentGlow { attributes[.shadow] = glow } // LOCAL-TUNER let result = NSMutableAttributedString(string: body, attributes: attributes) if Self.showsEditedMarker(for: message), !message.rendersAsLargeEmoji { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift index d11bf887f..e2213ce30 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift @@ -188,7 +188,7 @@ public class ChatColumnCell: UICollectionViewCell { /// 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. - public static var authorGutterWidth: CGFloat { ChatAuthorAvatarView.size + 8 } // LOCAL-TUNER + public static let authorGutterWidth = ChatAuthorAvatarView.size + 8 /// Whether a drag starting at this point, in the cell's own coordinates, may reply to the row. /// diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatDateSeparatorCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatDateSeparatorCell.swift index 2084e69e9..760ab8552 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatDateSeparatorCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatDateSeparatorCell.swift @@ -7,7 +7,6 @@ #if canImport(UIKit) import UIKit -import SwiftUI /// A centered day + time header between runs of messages (e.g. "Today 12:13 PM"). Dumb — it /// renders the already-formatted string it's handed. @@ -36,24 +35,23 @@ public final class ChatDateSeparatorCell: UICollectionViewCell { public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } public func configure(text: String) { - label.attributedText = Self.styled(text) // LOCAL-TUNER + label.attributedText = Self.styled(text) } - // LOCAL-TUNER: the day ("Oct 20", "Today") and the time ("15:40") set apart, each in its own type. - static func styled(_ text: String) -> NSAttributedString { // LOCAL-TUNER - let dayFont = UIFont.default(size: Tuner.value("Chat v2 Date/Day size", default: 12, in: 9...17, unit: .points), weight: Tuner.value("Chat v2 Date/Day weight", default: ChatTextWeight.medium).weight) // LOCAL-TUNER - let timeFont = UIFont.default(size: Tuner.value("Chat v2 Date/Time size", default: 12, in: 9...17, unit: .points), weight: Tuner.value("Chat v2 Date/Time weight", default: ChatTextWeight.regular).weight) // LOCAL-TUNER - let dayColor = UIColor(Tuner.value("Chat v2 Date/Day color", default: Color.white.opacity(0.5))) // LOCAL-TUNER - let timeColor = UIColor(Tuner.value("Chat v2 Date/Time color", default: Color.white.opacity(0.5))) // LOCAL-TUNER - let gap = Tuner.value("Chat v2 Date/Gap", default: 4, in: 0...16, unit: .points) // LOCAL-TUNER - guard let match = text.range(of: #"\d{1,2}[:.]\d{2}.*$"#, options: .regularExpression), match.lowerBound > text.startIndex else { // LOCAL-TUNER - return NSAttributedString(string: text, attributes: [.font: timeFont, .foregroundColor: timeColor]) // LOCAL-TUNER - } // LOCAL-TUNER - let day = text[.. NSAttributedString { + let dayFont = UIFont.default(size: 12, weight: .medium) + let timeFont = UIFont.default(size: 12, weight: .regular) + let color = UIColor.white.withAlphaComponent(0.5) + let gap: CGFloat = 4 + 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]) + } + let day = text[.. 0 else { return nil } // LOCAL-TUNER - let glow = NSShadow() // LOCAL-TUNER - glow.shadowColor = UIColor(accent.color).withAlphaComponent(sentGlowOpacity) // LOCAL-TUNER - glow.shadowBlurRadius = sentGlowRadius // LOCAL-TUNER - glow.shadowOffset = .zero // LOCAL-TUNER - return glow // LOCAL-TUNER - } // LOCAL-TUNER - static var sentTextStyle: ChatSentTextStyle { Tuner.value("Chat v2 Color/Sent text", default: ChatSentTextStyle.vivid) } // LOCAL-TUNER - /// How much of the accent the tinted sent text takes. // LOCAL-TUNER - static var sentTextTint: Double { Tuner.value("Chat v2 Color/Sent text tint", default: 0.175, in: 0...1, unit: .percent) } // LOCAL-TUNER + /// 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 var receivedFill: UIColor { UIColor(Tuner.value("Chat v2 Color/Received bubble", default: Color(r: 35, g: 35, b: 37))) } // LOCAL-TUNER + 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 var accentTint: UIColor { UIColor(accent.color.opacity(0.16)) } // LOCAL-TUNER + static let accentTint = UIColor(accent.opacity(0.16)) /// The ring around a reaction the viewer added. - static var accentRing: UIColor { UIColor(accent.color.opacity(0.5)) } // LOCAL-TUNER + static let accentRing = UIColor(accent.opacity(0.5)) /// The accent itself, for text and glyphs. - static var accentColor: UIColor { UIColor(accent.color) } // LOCAL-TUNER + static let accentColor = UIColor(accent) /// A bubble's fill for its sender. static func fill(isFromSelf: Bool) -> UIColor { @@ -130,177 +91,100 @@ enum ChatStyle { // MARK: - Group names /// A group author's name above their first bubble. - static var authorNameSize: CGFloat { Tuner.value("Chat v2 Group/Name size", default: 12, in: 10...17, unit: .points) } // LOCAL-TUNER - static var authorNameWeightChoice: ChatTextWeight { Tuner.value("Chat v2 Group/Name weight", default: ChatTextWeight.regular) } // LOCAL-TUNER - static var authorNameWeight: UIFont.Weight { authorNameWeightChoice.weight } // LOCAL-TUNER + 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. // LOCAL-TUNER - static var avatarStrokeWidth: CGFloat { Tuner.value("Chat v2 Avatar/Stroke width", default: 1, in: 0...3, unit: .points) } // LOCAL-TUNER - static var avatarStrokeOpacity: CGFloat { Tuner.value("Chat v2 Avatar/Stroke opacity", default: 0.05, in: 0...1, unit: .percent) } // LOCAL-TUNER - /// The avatar beside a group author's first bubble. // LOCAL-TUNER - static var authorAvatarSize: CGFloat { Tuner.value("Chat v2 Avatar/In-chat size", default: 28, in: 16...44, unit: .points) } // LOCAL-TUNER + /// 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 - static var replyIconSize: CGFloat { Tuner.value("Chat v2 Reply/Icon size", default: 24, in: 10...32, unit: .points) } // LOCAL-TUNER - static var replyIconColor: Color { Tuner.value("Chat v2 Reply/Icon color", default: Color.white.opacity(0.4)) } // LOCAL-TUNER + /// 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 - static var mentionStyle: ChatMentionStyle { Tuner.value("Chat v2 Mentions/Style", default: ChatMentionStyle.pill) } // LOCAL-TUNER - static var mentionPillFill: Color { Tuner.value("Chat v2 Mentions/Pill fill", default: Color.white.opacity(0.14)) } // LOCAL-TUNER - static var mentionPillFillSent: Color { Tuner.value("Chat v2 Mentions/Pill fill on sent", default: Color.white.opacity(0.14)) } // LOCAL-TUNER - static var mentionPaddingX: CGFloat { Tuner.value("Chat v2 Mentions/Padding X", default: 5.75, in: 0...14, unit: .points) } // LOCAL-TUNER - static var mentionPaddingY: CGFloat { Tuner.value("Chat v2 Mentions/Padding Y", default: 0, in: -2...8, unit: .points) } // LOCAL-TUNER - static var mentionGap: CGFloat { Tuner.value("Chat v2 Mentions/Gap to words", default: 0, in: 0...10, unit: .points) } // LOCAL-TUNER - static var mentionRadius: CGFloat { Tuner.value("Chat v2 Mentions/Corner radius", default: 7, in: 0...16, unit: .points) } // LOCAL-TUNER - static var mentionCapsule: Bool { Tuner.value("Chat v2 Mentions/Capsule", default: true) } // LOCAL-TUNER - static var mentionSize: CGFloat { Tuner.value("Chat v2 Mentions/Text size", default: 12.5, in: 10...22, unit: .points) } // LOCAL-TUNER - static var mentionBaselineNudge: CGFloat { Tuner.value("Chat v2 Mentions/Baseline nudge", default: 0.5, in: -4...4, unit: .points) } // LOCAL-TUNER - static var mentionWeight: ChatTextWeight { Tuner.value("Chat v2 Mentions/Weight", default: ChatTextWeight.semibold) } // LOCAL-TUNER - static var mentionColorMode: ChatMentionColor { Tuner.value("Chat v2 Mentions/Text color", default: ChatMentionColor.custom) } // LOCAL-TUNER - static var mentionCustomColor: Color { Tuner.value("Chat v2 Mentions/Custom color", default: Color.white.opacity(0.77)) } // LOCAL-TUNER - static var mentionUnderline: Bool { Tuner.value("Chat v2 Mentions/Underline", default: false) } // LOCAL-TUNER - /// A mention's text colour on a sender's bubble, or nil to keep the body's. // LOCAL-TUNER - static func mentionText(isFromSelf: Bool) -> UIColor? { // LOCAL-TUNER - switch mentionColorMode { // LOCAL-TUNER - case .inherit: nil // LOCAL-TUNER - case .accent: isFromSelf ? text(isFromSelf: true) : accentColor // LOCAL-TUNER - case .vivid: vividAccent // LOCAL-TUNER - case .custom: UIColor(mentionCustomColor) // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER + /// 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 var runGap: CGFloat { Tuner.value("Chat v2 Spacing/Within a run", default: 4, in: 0...12, unit: .points) } // LOCAL-TUNER + static let runGap: CGFloat = 4 /// Between two messages of one sender that the grouping window broke apart, and around a date. - static var momentGap: CGFloat { Tuner.value("Chat v2 Spacing/Same sender, new moment", default: 10, in: 0...24, unit: .points) } // LOCAL-TUNER + static let momentGap: CGFloat = 10 /// Between two speakers. - static var speakerGap: CGFloat { Tuner.value("Chat v2 Spacing/Speaker change", default: 16, in: 0...32, unit: .points) } // LOCAL-TUNER + static let speakerGap: CGFloat = 16 /// The transcript's side margin. - static var rowInset: CGFloat { Tuner.value("Chat v2 Spacing/Side margin", default: 16, in: 8...24, unit: .points) } // LOCAL-TUNER + static let rowInset: CGFloat = 16 // MARK: - Money card /// The money card's fill: neutral, so money reads the same under any accent. - static var cardFill: UIColor { UIColor(Tuner.value("Chat v2 Money/Card fill", default: Color(r: 28, g: 28, b: 30))) } // LOCAL-TUNER - static var cardRadius: CGFloat { Tuner.value("Chat v2 Money/Radius", default: 22, in: 8...32, unit: .points) } // LOCAL-TUNER - static var cardWidth: CGFloat { Tuner.value("Chat v2 Money/Width", default: 240, in: 180...320, unit: .points) } // LOCAL-TUNER - static var cardPaddingX: CGFloat { Tuner.value("Chat v2 Money/Padding X", default: 16, in: 8...28, unit: .points) } // LOCAL-TUNER - static var cardPaddingTop: CGFloat { Tuner.value("Chat v2 Money/Padding top", default: 14, in: 4...28, unit: .points) } // LOCAL-TUNER - static var cardPaddingBottom: CGFloat { Tuner.value("Chat v2 Money/Padding bottom", default: 22, in: 4...36, unit: .points) } // LOCAL-TUNER + 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 var cardHeaderGap: CGFloat { Tuner.value("Chat v2 Money/Header gap", default: 14, in: 0...32, unit: .points) } // LOCAL-TUNER + static let cardHeaderGap: CGFloat = 14 /// The token badge in the header. - static var cardBadgeSize: CGFloat { Tuner.value("Chat v2 Money/Badge size", default: 20, in: 12...32, unit: .points) } // LOCAL-TUNER - static var cardLabelSize: CGFloat { Tuner.value("Chat v2 Money/Label size", default: 13, in: 10...17, unit: .points) } // LOCAL-TUNER - static var cardAmountSize: CGFloat { Tuner.value("Chat v2 Money/Amount size", default: 34, in: 24...48, unit: .points) } // LOCAL-TUNER - static var cardFlagSize: CGFloat { Tuner.value("Chat v2 Money/Flag size", default: 30, in: 20...40, unit: .points) } // LOCAL-TUNER + 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 + /// The card's currency flag. + static let cardFlagSize: CGFloat = 30 /// The fill behind the cash badge's dollar glyph. - static var cashBadgeFill: UIColor { UIColor(Tuner.value("Chat v2 Money/Cash green", default: Color(r: 48, g: 209, b: 88))) } // LOCAL-TUNER + static let cashBadgeFill = UIColor(Color(r: 48, g: 209, b: 88)) // MARK: - Reactions - static var pillHeight: CGFloat { Tuner.value("Chat v2 Reactions/Height", default: 32, in: 20...36, unit: .points) } // LOCAL-TUNER - static var pillLeadingPadding: CGFloat { Tuner.value("Chat v2 Reactions/Padding leading", default: 10, in: 2...16, unit: .points) } // LOCAL-TUNER - static var pillTrailingPadding: CGFloat { Tuner.value("Chat v2 Reactions/Padding trailing", default: 2, in: 2...16, unit: .points) } // LOCAL-TUNER + /// 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 var pillGap: CGFloat { Tuner.value("Chat v2 Reactions/Gap", default: 4, in: 0...12, unit: .points) } // LOCAL-TUNER + static let pillGap: CGFloat = 4 /// Between a pill's emoji and its count. - static var pillInnerGap: CGFloat { Tuner.value("Chat v2 Reactions/Emoji to count", default: 4, in: 0...12, unit: .points) } // LOCAL-TUNER - static var pillCountRoom: Int { Tuner.value("Chat v2 Reactions/Count room (digits)", default: 2, in: 1...3) } // LOCAL-TUNER - static var pillEmojiSize: CGFloat { Tuner.value("Chat v2 Reactions/Emoji size", default: 13, in: 11...22, unit: .points) } // LOCAL-TUNER - static var pillCountSize: CGFloat { Tuner.value("Chat v2 Reactions/Count size", default: 13, in: 10...17, unit: .points) } // LOCAL-TUNER - static var pillRingWidth: CGFloat { Tuner.value("Chat v2 Reactions/Ring width", default: 1.75, in: 0...3, unit: .points) } // LOCAL-TUNER + 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 var pillFill: UIColor { UIColor(Tuner.value("Chat v2 Reactions/Fill", default: Color.white.opacity(0.09))) } // LOCAL-TUNER + static let pillFill = UIColor(Color.white.opacity(0.09)) /// Whether the add-reaction button sits on a glass circle, as in v1. - static var addReactionGlass: Bool { Tuner.value("Chat v2 Reactions/Add on glass", default: false) } // LOCAL-TUNER + static let addReactionGlass = false /// The add-reaction glyph. - static var addReactionIconSize: CGFloat { Tuner.value("Chat v2 Reactions/Add icon size", default: 19.3, in: 12...24, unit: .points) } // LOCAL-TUNER - - // LOCAL-TUNER: reads every value once, so all of them register in the panel and the transcript can observe them. - static func readAll() -> [Any] { // LOCAL-TUNER - [ChatDateSeparatorCell.styled("Oct 20 15:40"), Color.backgroundMain, replyIconSize, replyIconColor, mentionStyle, mentionPillFill, mentionPillFillSent, mentionPaddingX, mentionPaddingY, mentionGap, mentionRadius, mentionCapsule, mentionSize, mentionBaselineNudge, mentionWeight, mentionColorMode, mentionCustomColor, mentionUnderline, avatarStrokeWidth, avatarStrokeOpacity, authorAvatarSize, authorNameSize, authorNameWeightChoice, bubbleRadius, joinedRadius, cornerStyleChoice, bubblePaddingX, bubblePaddingY, bodySize, bodyWeightChoice, bodyLineHeight, drawsHairline, maxWidthFraction, // LOCAL-TUNER - accent, ChatAccent.custom.color, sentTextStyle, sentTextTint, sentOpacity, vividSaturation, vividBrightness, vividLift, vividInP3, sentGlowRadius, sentGlowOpacity, receivedFill, runGap, momentGap, speakerGap, rowInset, // LOCAL-TUNER - cardFill, cardRadius, cardWidth, cardPaddingX, cardPaddingTop, cardPaddingBottom, cardHeaderGap, cardBadgeSize, cardLabelSize, cardAmountSize, cardFlagSize, cashBadgeFill, // LOCAL-TUNER - pillHeight, pillLeadingPadding, pillTrailingPadding, pillGap, pillInnerGap, pillCountRoom, pillEmojiSize, pillCountSize, pillRingWidth, pillFill, addReactionGlass, addReactionIconSize] // LOCAL-TUNER - } // LOCAL-TUNER + static let addReactionIconSize: CGFloat = 19.3 } - -// LOCAL-TUNER: the choices behind the tuner's pickers. Baking keeps the picked value only. -nonisolated enum ChatAccent: String, TunableEnum, Sendable { // LOCAL-TUNER - case ink, graphite, smoke, blue, sky, cyan, teal, mint, lime, yellow, amber, orange, person, coral, red, rose, pink, magenta, purple, violet, indigo, navy, forest, brown, sand, custom // LOCAL-TUNER - var color: Color { // LOCAL-TUNER - switch self { // LOCAL-TUNER - case .ink: Self.hex(0xF2F2F5) // LOCAL-TUNER - case .graphite: Self.hex(0x48484A) // LOCAL-TUNER - case .smoke: Self.hex(0x8E8E93) // LOCAL-TUNER - case .blue: Self.hex(0x0A84FF) // LOCAL-TUNER - case .sky: Self.hex(0x5AC8FA) // LOCAL-TUNER - case .cyan: Self.hex(0x64D2FF) // LOCAL-TUNER - case .teal: Self.hex(0x30B0C7) // LOCAL-TUNER - case .mint: Self.hex(0x63E6BE) // LOCAL-TUNER - case .lime: Self.hex(0xA8E05F) // LOCAL-TUNER - case .yellow: Self.hex(0xFFD60A) // LOCAL-TUNER - case .amber: Self.hex(0xFFB340) // LOCAL-TUNER - case .orange: Self.hex(0xFF9F0A) // LOCAL-TUNER - case .person: Self.hex(0xE0723F) // LOCAL-TUNER - case .coral: Self.hex(0xFF6B5A) // LOCAL-TUNER - case .red: Self.hex(0xFF453A) // LOCAL-TUNER - case .rose: Self.hex(0xFF375F) // LOCAL-TUNER - case .pink: Self.hex(0xFF6FB5) // LOCAL-TUNER - case .magenta: Self.hex(0xE040A0) // LOCAL-TUNER - case .purple: Self.hex(0xBF5AF2) // LOCAL-TUNER - case .violet: Self.hex(0x7457FF) // LOCAL-TUNER - case .indigo: Self.hex(0x5E5CE6) // LOCAL-TUNER - case .navy: Self.hex(0x2C4BD4) // LOCAL-TUNER - case .forest: Self.hex(0x2E8B57) // LOCAL-TUNER - case .brown: Self.hex(0xAC8E68) // LOCAL-TUNER - case .sand: Self.hex(0xD8C3A5) // LOCAL-TUNER - case .custom: Tuner.value("Chat v2 Color/Custom accent", default: Self.hex(0x0A84FF)) // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER - /// Near-black on a light accent, white on a dark one, picked by the accent's luminance. // LOCAL-TUNER - var onColor: Color { // LOCAL-TUNER - let c = UIColor(color).cgColor.converted(to: CGColorSpace(name: CGColorSpace.sRGB)!, intent: .defaultIntent, options: nil)?.components ?? [0, 0, 0] // LOCAL-TUNER - let lin = { (v: CGFloat) in v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4) } // LOCAL-TUNER - let luminance = 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]) // LOCAL-TUNER - return luminance > 0.4 ? Self.hex(0x0B0B0C) : .white // LOCAL-TUNER - } // LOCAL-TUNER - private static func hex(_ value: Int) -> Color { // LOCAL-TUNER - Color(red: Double((value >> 16) & 0xFF) / 255, green: Double((value >> 8) & 0xFF) / 255, blue: Double(value & 0xFF) / 255) // LOCAL-TUNER - } // LOCAL-TUNER -} // LOCAL-TUNER - -nonisolated enum ChatSentTextStyle: String, TunableEnum, Sendable { // LOCAL-TUNER - case white, tinted, vivid, auto // LOCAL-TUNER -} // LOCAL-TUNER - -nonisolated enum ChatMentionStyle: String, TunableEnum, Sendable { case pill, plain } // LOCAL-TUNER -nonisolated enum ChatMentionColor: String, TunableEnum, Sendable { case inherit, accent, vivid, custom } // LOCAL-TUNER - -nonisolated enum ChatCornerStyle: String, TunableEnum, Sendable { // LOCAL-TUNER - case circular, continuous // LOCAL-TUNER - var style: RoundedCornerStyle { self == .circular ? .circular : .continuous } // LOCAL-TUNER -} // LOCAL-TUNER - -nonisolated enum ChatTextWeight: String, TunableEnum, Sendable { // LOCAL-TUNER - case regular, medium, semibold // LOCAL-TUNER - var weight: UIFont.Weight { // LOCAL-TUNER - switch self { case .regular: .regular; case .medium: .medium; case .semibold: .semibold } // LOCAL-TUNER - } // LOCAL-TUNER -} // LOCAL-TUNER - -private extension UIColor { // LOCAL-TUNER - func mixed(withWhite amount: CGFloat) -> UIColor { // LOCAL-TUNER - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 // LOCAL-TUNER - getRed(&r, green: &g, blue: &b, alpha: &a) // LOCAL-TUNER - return UIColor(red: r + (1 - r) * amount, green: g + (1 - g) * amount, blue: b + (1 - b) * amount, alpha: a) // LOCAL-TUNER - } // LOCAL-TUNER -} // LOCAL-TUNER #endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatSwipeToReply.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSwipeToReply.swift index 633f6ba9a..f412da28b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatSwipeToReply.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSwipeToReply.swift @@ -65,20 +65,13 @@ final class ChatSwipeToReply: NSObject { affordance.alpha = 0 affordanceIcon.image = UIImage.icon(.arrowShareLeftFilled) - affordanceIcon.tintColor = UIColor.white.withAlphaComponent(0.75) + 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) } - // LOCAL-TUNER: the reply glyph's size and colour from the tuner. - private func applyTuning() { // LOCAL-TUNER - let size = ChatStyle.replyIconSize // LOCAL-TUNER - let box = Self.affordanceDiameter // LOCAL-TUNER - affordanceIcon.frame = CGRect(x: (box - size) / 2, y: (box - size) / 2, width: size, height: size) // LOCAL-TUNER - affordanceIcon.tintColor = UIColor(ChatStyle.replyIconColor) // LOCAL-TUNER - } // LOCAL-TUNER - /// Whether a drag with this velocity is a reply swipe rather than a scroll. /// /// Horizontal dominance is the whole rule: a diagonal drag belongs to the scroll view, which @@ -162,7 +155,6 @@ final class ChatSwipeToReply: NSObject { haptics.prepare() affordance.alpha = 0 - applyTuning() // LOCAL-TUNER affordance.transform = CGAffineTransform(scaleX: Self.arrowStartScale, y: Self.arrowStartScale) affordance.center = Self.affordanceCenter(inRowOfHeight: row.cell.bounds.height) row.cell.contentView.addSubview(affordance) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatTypingIndicatorCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatTypingIndicatorCell.swift index af272b83d..b1a7ab48a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatTypingIndicatorCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatTypingIndicatorCell.swift @@ -35,9 +35,9 @@ 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 var avatarSize: CGFloat { ChatAuthorAvatarView.size } // LOCAL-TUNER + private static let avatarSize = ChatAuthorAvatarView.size private static let avatarOverlap: CGFloat = 8 private static let avatarGap: CGFloat = 4 private static let avatarRing: CGFloat = 2 diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 82dd8d5be..962cbb3c0 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -324,36 +324,8 @@ public final class ChatViewController: UICollectionViewController { @available(*, unavailable) public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - private var tunerRestylePending = false // LOCAL-TUNER - // LOCAL-TUNER: reloads without animation and puts the transcript back where its bottom was. - private func restyleForTuner() { // LOCAL-TUNER - tunerRestylePending = false // LOCAL-TUNER - guard !isUpdating else { tunerRestylePending = true; DispatchQueue.main.async { [weak self] in self?.restyleForTuner() }; return } // LOCAL-TUNER - UIView.performWithoutAnimation { // LOCAL-TUNER - collectionView.backgroundColor = UIColor(Color.backgroundMain) // LOCAL-TUNER - let snapshot = chatLayout.getContentOffsetSnapshot(from: .bottom) // LOCAL-TUNER - collectionView.reloadData() // LOCAL-TUNER - if let snapshot { chatLayout.restoreContentOffset(with: snapshot) } // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER - - // LOCAL-TUNER: redraws the transcript whenever a Chat v2 tuner value changes. - private func observeChatStyle() { // LOCAL-TUNER - withObservationTracking { _ = ChatStyle.readAll() } onChange: { // LOCAL-TUNER - Task { @MainActor [weak self] in // LOCAL-TUNER - guard let self else { return } // LOCAL-TUNER - observeChatStyle() // LOCAL-TUNER - guard !tunerRestylePending else { return } // LOCAL-TUNER - tunerRestylePending = true // LOCAL-TUNER - // One restyle per frame however fast the slider moves, holding the bottom in place. // LOCAL-TUNER - DispatchQueue.main.async { [weak self] in self?.restyleForTuner() } // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER - public override func viewDidLoad() { super.viewDidLoad() - observeChatStyle() // LOCAL-TUNER collectionView.backgroundColor = UIColor(Color.backgroundMain) collectionView.alwaysBounceVertical = true collectionView.keyboardDismissMode = .interactive diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift index 99684fb67..75a5ec31c 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift @@ -31,14 +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 var diameter: CGFloat { Tuner.value("Chat v2 Jump/Size", default: 36, in: 28...64, unit: .points) } // LOCAL-TUNER + public static let diameter: CGFloat = 36 /// 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 var reach: CGSize { CGSize(width: 88, height: 20 + diameter + 12) } // LOCAL-TUNER - private static var badgeHeight: CGFloat { Tuner.value("Chat v2 Jump/Badge size", default: 22, in: 14...32, unit: .points) } // LOCAL-TUNER + public static let reach = CGSize(width: 88, height: 20 + diameter + 12) + private static let badgeHeight: CGFloat = 22 public let unreadCount: Int public let action: () -> Void @@ -55,7 +55,7 @@ public struct JumpToBottomButtonView: View { let badgeText = UnreadBadgeText.text(for: unreadCount) Button(action: action) { Image.icon(.chevronDownMedium) - .iconSize(Tuner.value("Chat v2 Jump/Icon size", default: 22, in: 12...32, unit: .points)) // LOCAL-TUNER + .iconSize(22) .foregroundStyle(.white) .frame(width: Self.diameter, height: Self.diameter) .composerGlass(in: Circle(), id: "jump", namespace: glassNamespace) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift index 13165b866..def06fc2d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift @@ -299,7 +299,6 @@ 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) - textView.isSent = message.sender == .me // LOCAL-TUNER // 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. @@ -486,15 +485,13 @@ extension LinkableBubbleView { for mention in message.linkPreview?.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 isFromSelf = message.sender == .me // LOCAL-TUNER - let mentionFont = UIFont.default(size: ChatStyle.mentionSize, weight: ChatStyle.mentionWeight.weight) // LOCAL-TUNER - result.addAttribute(.font, value: mentionFont, range: mention.range) // LOCAL-TUNER - // Lifts a smaller handle so its capitals centre on the body's, instead of sitting on its baseline. // LOCAL-TUNER - let centring = (ChatStyle.bodyFont.capHeight - mentionFont.capHeight) / 2 + ChatStyle.mentionBaselineNudge // LOCAL-TUNER - let bodyOffset = (ChatStyle.bodyLineAttributes(for: ChatStyle.bodyFont)[.baselineOffset] as? CGFloat) ?? 0 // LOCAL-TUNER - result.addAttribute(.baselineOffset, value: bodyOffset + centring, range: mention.range) // LOCAL-TUNER - if let color = ChatStyle.mentionText(isFromSelf: isFromSelf) { result.addAttribute(.foregroundColor, value: color, range: mention.range) } // LOCAL-TUNER - if ChatStyle.mentionUnderline { result.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: mention.range) } // LOCAL-TUNER + 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 { @@ -569,19 +566,17 @@ extension LinkableBubbleView: UITextViewDelegate { /// The rounded background drawn behind an `@handle`. enum MentionPill { - static var fill: UIColor { UIColor(ChatStyle.mentionPillFill) } // LOCAL-TUNER - static var isDrawn: Bool { ChatStyle.mentionStyle == .pill } // LOCAL-TUNER + static let fill = UIColor.white.withAlphaComponent(0.14) /// Horizontal room between the handle's glyphs and the pill's edge. - static var padding: CGFloat { isDrawn ? ChatStyle.mentionPaddingX : 0 } // LOCAL-TUNER + static let padding: CGFloat = 5.75 /// Space between the pill's edge and the neighbouring words. - static var gap: CGFloat { isDrawn ? ChatStyle.mentionGap : 0 } // LOCAL-TUNER + static let gap: CGFloat = 0 /// Kern either side of the handle, making room for the padding and the gap. - static var spacing: CGFloat { padding + gap } // LOCAL-TUNER - static var cornerRadius: CGFloat { ChatStyle.mentionRadius } // LOCAL-TUNER + static let spacing: CGFloat = padding + gap /// The pill for one line of a mention, from that line's selection rect. static func rect(around line: CGRect) -> CGRect { - line.insetBy(dx: -padding, dy: 0) // LOCAL-TUNER + line.insetBy(dx: -padding, dy: 0) } } @@ -590,8 +585,6 @@ enum MentionPill { /// ChatLayout's own `MessageTextView` recipe. final class LinkTextView: UITextView { private let pills = CAShapeLayer() - /// Whether this text sits in a sent bubble, whose mention pills take their own fill. // LOCAL-TUNER - var isSent = false { didSet { setNeedsLayout() } } // LOCAL-TUNER override init(frame: CGRect, textContainer: NSTextContainer?) { super.init(frame: frame, textContainer: textContainer) @@ -614,23 +607,21 @@ final class LinkTextView: UITextView { /// One pill per line a mention covers, behind the text, so a wrapped handle gets one on each line. private func drawPills() { let path = UIBezierPath() - for rect in MentionPill.isDrawn ? mentionPillRects : [] { // LOCAL-TUNER - path.append(UIBezierPath(roundedRect: rect, cornerRadius: ChatStyle.mentionCapsule ? rect.height / 2 : MentionPill.cornerRadius)) // LOCAL-TUNER + for rect in mentionPillRects { + path.append(UIBezierPath(roundedRect: rect, cornerRadius: rect.height / 2)) } - pills.fillColor = (isSent ? UIColor(ChatStyle.mentionPillFillSent) : MentionPill.fill).cgColor // LOCAL-TUNER pills.path = path.cgPath pills.frame = bounds } - // LOCAL-TUNER: the pill sized to the handle's own type, centred on its glyphs rather than filling - // the body's line, so a smaller handle gets a smaller pill. - private func fittedPill(_ line: CGRect, range: NSRange) -> CGRect { // LOCAL-TUNER - let wide = MentionPill.rect(around: line) // LOCAL-TUNER - guard let start = position(from: beginningOfDocument, offset: range.location + min(1, range.length)) else { return wide } // LOCAL-TUNER - let glyphs = caretRect(for: start) // LOCAL-TUNER - let height = glyphs.height + ChatStyle.mentionPaddingY * 2 // LOCAL-TUNER - return CGRect(x: wide.minX, y: glyphs.midY - height / 2, width: wide.width, height: height) // LOCAL-TUNER - } // LOCAL-TUNER + /// 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] { @@ -644,11 +635,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 { self.fittedPill($0, range: range) } // LOCAL-TUNER + result += lines.map { self.fittedPill($0, range: range) } return } let width = Self.unkerned(text.attributedSubstring(from: range)).size().width - result.append(fittedPill(CGRect(x: end - width, y: line.minY, width: width, height: line.height), range: range)) // LOCAL-TUNER + 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 df0e49b03..085325f82 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift @@ -26,7 +26,7 @@ import UIKit final class MessageBackdrop { /// How long the blur takes to fade in or out. - private static var fadeDuration: TimeInterval { Tuner.value("Chat v2 Lift/Fade duration", default: 0.2, in: 0...1, unit: .seconds) } // LOCAL-TUNER + private static let fadeDuration: TimeInterval = 0.2 /// Called when the held blur is tapped — the way out of an edit, as tapping outside the message /// is in WhatsApp. Never fires while a lift owns the screen: the lift's window sits above the @@ -67,14 +67,14 @@ final class MessageBackdrop { // available to animate. blurStrength = ChatBackdrop.frost(blur) - // LOCAL-TUNER: the dim from the first frame, under the menu too, so its slider reads at once. - let earlyDim = UIView(frame: blur.bounds) // LOCAL-TUNER - earlyDim.backgroundColor = .black // LOCAL-TUNER - earlyDim.alpha = ChatBackdrop.dimAlpha // LOCAL-TUNER - earlyDim.autoresizingMask = [.flexibleWidth, .flexibleHeight] // LOCAL-TUNER - earlyDim.isUserInteractionEnabled = false // LOCAL-TUNER - blur.contentView.addSubview(earlyDim) // LOCAL-TUNER - dim = earlyDim // LOCAL-TUNER + // 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 } } @@ -100,7 +100,7 @@ final class MessageBackdrop { // 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 = self.dim ?? UIView(frame: blur.bounds) // LOCAL-TUNER + let dim = self.dim ?? UIView(frame: blur.bounds) dim.backgroundColor = .black dim.autoresizingMask = [.flexibleWidth, .flexibleHeight] dim.isUserInteractionEnabled = false diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ReactionPillView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ReactionPillView.swift index 170bb2f7a..d9cc6215a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ReactionPillView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ReactionPillView.swift @@ -85,7 +85,7 @@ final class ReactionPillView: UIView { rollCount(up: pill.count > previous.count) } countLabel.text = "\(pill.count)" - countWidth.constant = Self.countWidth(digits: max(min(countDigits, ChatStyle.pillCountRoom), "\(pill.count)".count)) // LOCAL-TUNER + countWidth.constant = Self.countWidth(digits: max(min(countDigits, ChatStyle.pillCountRoom), "\(pill.count)".count)) let fill = pill.selfReacted ? Self.selfReactedFill : Self.restingFill let borderWidth = pill.selfReacted ? Self.selfReactedBorderWidth : 0 diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift index 88bd94d14..323161512 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift @@ -35,7 +35,7 @@ extension ShapeStyle where Self == Color { public static var bannerInfo: Color { Color("backgroundSecondary") } public static var bannerSuccess: Color { Color("backgroundSecondary") } - public static var backgroundMain: Color { Tuner.value("App/Background", default: Color(r: 18, g: 18, b: 18)) } // LOCAL-TUNER + public static var backgroundMain: Color { Color("background") } public static var backgroundSecondary: Color { Color("backgroundSecondary") } public static var backgroundRow: Color { Color.white.opacity(0.05) } public static var rowSeparator: Color { Color.white.opacity(0.1) } diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayAmountMetrics.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayAmountMetrics.swift index cc180bbd7..318c7dcba 100644 --- a/FlipcashUI/Sources/FlipcashUI/Tray/TrayAmountMetrics.swift +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayAmountMetrics.swift @@ -8,22 +8,16 @@ import SwiftUI /// The amount tray's sizes and gaps: the full-screen entry's, scaled to a tray. -/// -/// Every value is live in the tuner during the feel round and bakes back to a constant in Phase 2. @MainActor enum TrayAmountMetrics { /// The amount's size; the full-screen entry's is 74. - static var amountSize: CGFloat { points("Amount size", 60, 40...74) } // LOCAL-TUNER + 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 var amountToCaption: CGFloat { points("Amount → caption", 4, 0...24) } // LOCAL-TUNER - static var captionSize: CGFloat { points("Caption size", 16, 11...20) } // LOCAL-TUNER - static var captionToKeypad: CGFloat { points("Caption → keypad", 24, 0...64) } // LOCAL-TUNER + 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 var keypadSideBleed: CGFloat { points("Keypad side bleed", 16, 0...24) } // LOCAL-TUNER - - private static func points(_ name: String, _ value: CGFloat, _ range: ClosedRange) -> CGFloat { - Tuner.shared.value("Tray Amount/\(name)", default: value, in: range, unit: .points) // LOCAL-TUNER - } + static let keypadSideBleed: CGFloat = 16 } diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayMetrics.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayMetrics.swift index 3dc28d556..e67d10c9a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Tray/TrayMetrics.swift +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayMetrics.swift @@ -10,30 +10,28 @@ 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. -/// -/// Every value is live in the tuner during the feel round and bakes back to a constant in Phase 2. @MainActor enum TrayMetrics { // MARK: - Layout - static var padding: CGFloat { layout("Padding", 24, 0...48) } // LOCAL-TUNER - static var iconToTitle: CGFloat { layout("Icon → title", 12, 0...40) } // LOCAL-TUNER - static var titleToBody: CGFloat { layout("Title → body", 12, 0...32) } // LOCAL-TUNER - static var bodyToContent: CGFloat { layout("Body → content", 16, 0...40) } // LOCAL-TUNER - static var contentToActions: CGFloat { layout("Content → CTA", 24, 0...48) } // LOCAL-TUNER - static var iconSize: CGFloat { layout("Icon size", 64, 0...96) } // LOCAL-TUNER - static var closeSize: CGFloat { layout("Close size", 36, 24...48) } // LOCAL-TUNER + 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 var closeGlyphSize: CGFloat { layout("Close glyph", 12, 6...20) } // LOCAL-TUNER - static var closeStroke: CGFloat { layout("Close stroke", 2.5, 1...4) } // LOCAL-TUNER - static var closeGlyphOpacity: Double { Tuner.shared.value("Tray Layout/Close glyph opacity", default: 1.0, in: 0...1) } // LOCAL-TUNER - static var closeFillOpacity: Double { Tuner.shared.value("Tray Layout/Close fill opacity", default: 0.05, in: 0...0.3) } // LOCAL-TUNER - static var actionHeight: CGFloat { layout("CTA height", 54, 36...64) } // LOCAL-TUNER - static var actionGap: CGFloat { layout("CTA gap", 12, 0...24) } // LOCAL-TUNER + 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 var topRadius: CGFloat { layout("Top radius", 39.5, 0...60) } // LOCAL-TUNER + static let topRadius: CGFloat = 39.5 /// The edge-attached iOS 18 sheet's top corners, which the card matches. static let edgeAttachedTopRadius: CGFloat = 10 @@ -45,16 +43,15 @@ enum TrayMetrics { // 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 var titleSize: CGFloat { type("Title size", 24, 14...34) } // LOCAL-TUNER + static let titleSize: CGFloat = 24 /// A title in the header row, beside the X, for a step with no icon. - static var headerTitleSize: CGFloat { type("Header title size", 20, 14...28) } // LOCAL-TUNER - static var titleWeight: Font.Weight { Tuner.shared.value("Tray Type/Title weight", default: TrayTitleWeight.semibold).weight } // LOCAL-TUNER - static var titleTracking: CGFloat { type("Title tracking", -0.22, -2...2) } // LOCAL-TUNER - static var bodySize: CGFloat { type("Body size", 15, 10...20) } // LOCAL-TUNER - static var bodyLineHeight: CGFloat { type("Body line height", 20, 12...30) } // LOCAL-TUNER - static var bodyOpacity: Double { Tuner.shared.value("Tray Type/Body opacity", default: 0.6, in: 0...1) } // LOCAL-TUNER - static var bodyStyle: TrayBodyStyle { Tuner.shared.value("Tray Type/Body style", default: TrayBodyStyle.vibrant) } // LOCAL-TUNER - static var actionSize: CGFloat { type("CTA size", 16, 12...22) } // LOCAL-TUNER + 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. @@ -74,81 +71,44 @@ enum TrayMetrics { // MARK: - Fill /// Whether the card is Liquid Glass (iOS 26+) or a material (iOS 18) instead of the flat fill. - static var usesGlass: Bool { Tuner.shared.value("Tray Fill/Liquid Glass", default: false) } // LOCAL-TUNER - static var flatOpacity: Double { Tuner.shared.value("Tray Fill/Flat opacity", default: 0.5, in: 0...1) } // LOCAL-TUNER + static let usesGlass = false + static let flatOpacity: Double = 0.5 /// Whether the system dims the screen behind the tray. - static var dimsBackground: Bool { Tuner.shared.value("Tray Fill/System dim", default: true) } // LOCAL-TUNER + static let dimsBackground = true // MARK: - Rows - static var rowHeight: CGFloat { Tuner.shared.value("Tray Rows/Height", default: 56, in: 40...72, unit: .points) } // LOCAL-TUNER - static var rowRadius: CGFloat { Tuner.shared.value("Tray Rows/Radius", default: 16, in: 0...36, unit: .points) } // LOCAL-TUNER - static var rowGap: CGFloat { Tuner.shared.value("Tray Rows/Gap", default: 8, in: 0...20, unit: .points) } // LOCAL-TUNER - static var rowFillOpacity: Double { Tuner.shared.value("Tray Rows/Fill opacity", default: 0.06, in: 0...0.3) } // LOCAL-TUNER - static var rowSelectedFillOpacity: Double { Tuner.shared.value("Tray Rows/Selected fill opacity", default: 0.14, in: 0...0.4) } // LOCAL-TUNER - static var rowIconSize: CGFloat { Tuner.shared.value("Tray Rows/Icon size", default: 22, in: 14...32, unit: .points) } // LOCAL-TUNER - static var rowTextSize: CGFloat { Tuner.shared.value("Tray Rows/Text size", default: 17, in: 13...22, unit: .points) } // LOCAL-TUNER - static var rowTextWeight: Font.Weight { Tuner.shared.value("Tray Rows/Text weight", default: TrayTitleWeight.semibold).weight } // LOCAL-TUNER - static var tileHeight: CGFloat { Tuner.shared.value("Tray Rows/Tile height", default: 64, in: 44...96, unit: .points) } // LOCAL-TUNER - static var tileRadius: CGFloat { Tuner.shared.value("Tray Rows/Tile radius", default: 16, in: 0...36, unit: .points) } // LOCAL-TUNER - static var tileSelection: TrayTileSelection { Tuner.shared.value("Tray Rows/Tile selection", default: TrayTileSelection.raised) } // LOCAL-TUNER - static var rowHaptic: TrayRowHaptic { Tuner.shared.value("Tray Rows/Haptic", default: TrayRowHaptic.selection) } // LOCAL-TUNER - static var rowPressScale: CGFloat { Tuner.shared.value("Tray Rows/Press scale", default: 0.98, in: 0.9...1) } // LOCAL-TUNER + 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 keys keep the "Tray Destructive" group the app's floating tuner lists. // LOCAL-TUNER /// The red at the error card's top edge. - static var errorFadeStrength: Double { Tuner.shared.value("Tray Destructive/Fade strength", default: 0.39, in: 0...1) } // LOCAL-TUNER + 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 var errorFadeLength: Double { Tuner.shared.value("Tray Destructive/Fade length", default: 0.865, in: 0.1...1) } // LOCAL-TUNER + static let errorFadeLength: Double = 0.865 /// How much darker the error card is than a standard tray, under the red. - static var errorFadeDarkness: Double { Tuner.shared.value("Tray Destructive/Fade card darkness", default: 0.53, in: 0...1) } // LOCAL-TUNER + static let errorFadeDarkness: Double = 0.53 // MARK: - Behavior - static var actionPress: TrayActionPress { Tuner.shared.value("Tray Behavior/CTA press", default: TrayActionPress.scale) } // LOCAL-TUNER - static var actionPressScale: CGFloat { Tuner.shared.value("Tray Behavior/CTA press scale", default: 0.95, in: 0.9...1) } // LOCAL-TUNER - static var actionPressDim: Double { Tuner.shared.value("Tray Behavior/CTA press dim", default: 0.7, in: 0.3...1) } // LOCAL-TUNER - - // MARK: - Tuner - - private static func layout(_ name: String, _ value: CGFloat, _ range: ClosedRange) -> CGFloat { - Tuner.shared.value("Tray Layout/\(name)", default: value, in: range, unit: .points) // LOCAL-TUNER - } - - private static func type(_ name: String, _ value: CGFloat, _ range: ClosedRange) -> CGFloat { - Tuner.shared.value("Tray Type/\(name)", default: value, in: range, unit: .points) // LOCAL-TUNER - } -} - -/// The title weights the tuner offers. -enum TrayTitleWeight: TunableEnum { // LOCAL-TUNER: conformance - case regular, medium, semibold, bold, heavy - - var weight: Font.Weight { - switch self { - case .regular: .regular - case .medium: .medium - case .semibold: .semibold - case .bold: .bold - case .heavy: .heavy - } - } -} - -/// The body text's ink. -enum TrayBodyStyle: TunableEnum { // LOCAL-TUNER: conformance - /// White at the body opacity. - case flat - /// White at the body opacity, blended plus-lighter so the card's colour shows through. - case vibrant - /// SwiftUI's `.secondary` hierarchical style. - case hierarchical + 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: TunableEnum { // LOCAL-TUNER: conformance +enum TrayTileSelection { /// The row's raised fill. case raised /// A white ring on the resting fill. @@ -157,28 +117,8 @@ enum TrayTileSelection: TunableEnum { // LOCAL-TUNER: conformance case inverted } -/// The haptic a tray row plays on tap. -enum TrayRowHaptic: TunableEnum { // LOCAL-TUNER: conformance - case none, selection, softest, soft, medium - - /// Kept prepared between taps, so fast typing gets its haptic on time. - @MainActor private static let selectionGenerator = UISelectionFeedbackGenerator() - - @MainActor func play() { - switch self { - case .none: break - case .selection: - Self.selectionGenerator.selectionChanged() - Self.selectionGenerator.prepare() - case .softest: Haptics.softest() - case .soft: Haptics.soft() - case .medium: Haptics.medium() - } - } -} - /// How the CTA answers a press. -enum TrayActionPress: TunableEnum { // LOCAL-TUNER: conformance +enum TrayActionPress { case none, scale, dim /// The press to draw: Reduce Motion turns a scale into a dim, keeping feedback without diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayMotion.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayMotion.swift index 18aa75e0c..8a2568380 100644 --- a/FlipcashUI/Sources/FlipcashUI/Tray/TrayMotion.swift +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayMotion.swift @@ -14,75 +14,49 @@ import SwiftUI nonisolated enum TrayMotion { /// The card's height, and the chrome and page with it, as a step changes. - static var resize: ChatSpring { spring("Tray Motion/Resize", 0.35, 0) } // LOCAL-TUNER + static let resize = ChatSpring(duration: 0.35, bounce: 0) /// The incoming page fading and unblurring into place. - static var contentIn: ChatSpring { spring("Tray Motion/Content in", 0.27, 0) } // LOCAL-TUNER + 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 var contentOut: ChatSpring { spring("Tray Motion/Content out", 0.20, 0) } // LOCAL-TUNER + static let contentOut = ChatSpring(duration: 0.20, bounce: 0) /// The action's old title clearing; quicker than the new one arriving. - static var labelOut: ChatSpring { spring("Tray Label/Out", 0.17, 0) } // LOCAL-TUNER + static let labelOut = ChatSpring(duration: 0.17, bounce: 0) /// The action's new title settling into place. - static var labelIn: ChatSpring { spring("Tray Label/In", 0.34, 0.1) } // LOCAL-TUNER + 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 var labelInDelay: TimeInterval { - Tuner.value("Tray Label/In delay", default: 0.045, in: 0...0.2, unit: .seconds) // LOCAL-TUNER - } + static let labelInDelay: TimeInterval = 0.045 /// The blur a title carries at either end of its change. - static var labelBlur: CGFloat { - Tuner.value("Tray Label/Blur", default: 0, in: 0...10, unit: .points) // LOCAL-TUNER - } + 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 var labelRise: CGFloat { - Tuner.value("Tray Label/Rise", default: 0, in: 0...20, unit: .points) // LOCAL-TUNER - } + static let labelRise: CGFloat = 0 /// The scale a title holds at either end of its change. - static var labelScale: CGFloat { - Tuner.value("Tray Label/Scale", default: 1, in: 0.8...1) // LOCAL-TUNER - } + static let labelScale: CGFloat = 1 /// Whether a step back reverses the rise, so the title falls the way the flow went. - static var labelFollowsDirection: Bool { - Tuner.value("Tray Label/Follow direction", default: true) // LOCAL-TUNER - } + static let labelFollowsDirection = true /// How long the incoming page waits, so it lands as the outgoing one clears. - static var contentInDelay: TimeInterval { - Tuner.value("Tray Motion/Content in delay", default: 0.05, in: 0...0.3, unit: .seconds) // LOCAL-TUNER - } + static let contentInDelay: TimeInterval = 0.05 /// The blur a page carries at either end of its crossfade. - static var pageBlur: CGFloat { - Tuner.value("Tray Motion/Page blur", default: 2, in: 0...20, unit: .points) // LOCAL-TUNER - } + static let pageBlur: CGFloat = 2 /// Whether a page scales as it crossfades. - static var pageScales: Bool { - Tuner.value("Tray Motion/Page scale", default: true) // LOCAL-TUNER - } + static let pageScales = true /// The scale a leaving page shrinks to. - static var pageScaleOut: CGFloat { - Tuner.value("Tray Motion/Scale out", default: 0.96, in: 0.85...1) // LOCAL-TUNER - } + static let pageScaleOut: CGFloat = 0.96 /// The scale an arriving page grows from. - static var pageScaleIn: CGFloat { - Tuner.value("Tray Motion/Scale in from", default: 0.96, in: 0.85...1) // LOCAL-TUNER - } - - // Bakes back to `ChatSpring(duration:bounce:)` lets. - private static func spring(_ key: String, _ duration: TimeInterval, _ bounce: Double) -> ChatSpring { - let spring = Tuner.value(key, default: Spring(duration: duration, bounce: bounce), form: "ChatSpring(duration: {duration}, bounce: {bounce})") // LOCAL-TUNER - return ChatSpring(duration: spring.duration, bounce: spring.bounce) - } + static let pageScaleIn: CGFloat = 0.96 } extension ChatSpring { diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayOptionRow.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayOptionRow.swift index b386cf338..2e46bc4fb 100644 --- a/FlipcashUI/Sources/FlipcashUI/Tray/TrayOptionRow.swift +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayOptionRow.swift @@ -6,6 +6,7 @@ // 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. @@ -65,6 +66,9 @@ public struct TrayOptionRow: View { /// 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 { @@ -73,7 +77,10 @@ struct TrayRowPressStyle: ButtonStyle { .modifier(Press(isPressed: isPressed, reduceMotion: reduceMotion)) // On touch down, as a physical control answers, not on release. .onChange(of: isPressed) { _, isPressed in - if isPressed { TrayMetrics.rowHaptic.play() } + if isPressed { + Self.selectionGenerator.selectionChanged() + Self.selectionGenerator.prepare() + } } } } @@ -97,6 +104,6 @@ private struct Press: ViewModifier { } extension TrayOptionRow { - /// The space between stacked rows. // LOCAL-TUNER - @MainActor public static var gap: CGFloat { TrayMetrics.rowGap } // LOCAL-TUNER + /// The space between stacked rows. + @MainActor public static let gap: CGFloat = TrayMetrics.rowGap } diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayPage.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayPage.swift index 4e3b632ac..a2271779d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Tray/TrayPage.swift +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayPage.swift @@ -49,7 +49,9 @@ public struct TrayPage: View { Text(message) .font(.system(size: TrayMetrics.scaled(TrayMetrics.bodySize, .subheadline, dynamicTypeSize))) .lineSpacing(TrayMetrics.bodyLineSpacing(dynamicTypeSize)) - .trayBodyStyle(TrayMetrics.bodyStyle) // LOCAL-TUNER + // 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) } @@ -81,19 +83,3 @@ struct TrayTitleKey: PreferenceKey { value = nextValue() ?? value } } - -extension View { - /// The body's ink: a flat colour, or vibrancy that lets the card's colour show through. // LOCAL-TUNER - @ViewBuilder // LOCAL-TUNER - func trayBodyStyle(_ style: TrayBodyStyle) -> some View { // LOCAL-TUNER - switch style { // LOCAL-TUNER - case .flat: // LOCAL-TUNER - foregroundStyle(Color.textMain.opacity(TrayMetrics.bodyOpacity)) // LOCAL-TUNER - case .vibrant: // LOCAL-TUNER - // Dark-mode vibrancy: light ink added onto what's beneath it. // LOCAL-TUNER - foregroundStyle(Color.white.opacity(TrayMetrics.bodyOpacity)).blendMode(.plusLighter) // LOCAL-TUNER - case .hierarchical: // LOCAL-TUNER - foregroundStyle(.secondary) // LOCAL-TUNER - } // LOCAL-TUNER - } // LOCAL-TUNER -} // LOCAL-TUNER diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TraySandbox.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TraySandbox.swift index bd2b5943e..38e05272c 100644 --- a/FlipcashUI/Sources/FlipcashUI/Tray/TraySandbox.swift +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TraySandbox.swift @@ -17,11 +17,9 @@ import FlipcashCore /// `--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`, or the tuner's "Tray Behavior/Open tray - /// sandbox" is on, which holds across launches from the home screen. // LOCAL-TUNER + /// Whether the app was launched with `--tray-sandbox`. @MainActor public static var isRequested: Bool { ProcessInfo.processInfo.arguments.contains("--tray-sandbox") - || Tuner.shared.value("Tray Behavior/Open tray sandbox", default: false) // LOCAL-TUNER } /// Whether the deposit flow plays itself (`--tray-sandbox-autoplay`): open, forward to the end, @@ -73,13 +71,6 @@ public struct TraySandbox: View { open(requested) } } - // A release in these groups replays nothing rather than stepping the flow. - .onTunerReplay("Tray Layout") {} // LOCAL-TUNER - .onTunerReplay("Tray Type") {} // LOCAL-TUNER - .onTunerReplay("Tray Fill") {} // LOCAL-TUNER - .onTunerReplay("Tray Behavior") {} // LOCAL-TUNER - .onTunerReplay("Tray Rows") {} // LOCAL-TUNER - .onTunerReplay("Tray Amount") {} // LOCAL-TUNER } private func open(_ kind: TrayKind) { @@ -316,7 +307,7 @@ private struct DepositFlowTray: View { } @State private var flow = TrayFlow(root: Step.education) - /// Which way a tuner replay walks the flow; it turns at either end. + /// Which way an autoplay step walks the flow; it turns at either end. @State private var replaysForward = true @Environment(\.dismiss) private var dismiss @@ -340,9 +331,6 @@ private struct DepositFlowTray: View { TrayPage(title: "Confirm Deposit", message: "We'll let you know when it arrives.") } } - // A motion release walks the flow forward to the end, then back. - .onTunerReplay("Tray Motion") { replayStep() } // LOCAL-TUNER - .onTunerReplay("Tray Label") { replayStep() } // LOCAL-TUNER .task { guard TraySandbox.autoplays else { return } for _ in 0..<4 { @@ -606,7 +594,6 @@ private struct AmountEntryBackdrop: View { .padding(.bottom, 32) .accessibilityHidden(true) - // Below the keypad, where the tuner panel never docks. Menu { ForEach(TrayKind.allCases) { kind in Button(kind.name) { open(kind) } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift b/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift index e89c1f2c4..7b3338e1c 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift @@ -73,7 +73,7 @@ public struct ContactAvatarView: View { } .frame(width: size, height: size) .clipShape(clipShape) - .overlay(Circle().strokeBorder(Color.white.opacity(ChatStyle.avatarStrokeOpacity), lineWidth: ChatStyle.avatarStrokeWidth)) // LOCAL-TUNER + .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 index 6cbe4469c..d570fc2c3 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/ListRow.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/ListRow.swift @@ -52,7 +52,7 @@ public struct ListRow: View { Text(title) .foregroundStyle(titleColor) .lineLimit(1) - .font(.default(size: Tuner.shared.value("Rows/Title size", default: CGFloat(16), in: 13...24, unit: .points), weight: [Font.Weight.regular, .medium, .semibold, .bold][Tuner.shared.value("Rows/Title weight (1 regular–4 bold)", default: 2, in: 1...4) - 1])) // LOCAL-TUNER + .font(.default(size: 16, weight: .medium)) if let subtitle { Text(subtitle) .font(.appTextSmall) @@ -70,7 +70,7 @@ public struct ListRow: View { if showsDivider { Rectangle() .fill(Color.rowSeparator) - .frame(height: Tuner.shared.value("Rows/Divider weight", default: CGFloat(1.0 / 3.0), in: 0...2, unit: .points)) // LOCAL-TUNER + .frame(height: 1.0 / 3.0) .padding(.trailing, ListRowMetrics.dividerTrailingInset) } } @@ -97,33 +97,33 @@ public enum ListRowTone { } /// The row's metrics, shared by `ListRow` and `ListSectionTitle` so a group of rows stays aligned. -@MainActor public enum ListRowMetrics { // LOCAL-TUNER +public enum ListRowMetrics { /// Screen-edge inset on both sides. - public static var margin: CGFloat { Tuner.shared.value("Rows/Side margin", default: CGFloat(20), in: 0...32, unit: .points) } // LOCAL-TUNER + public static let margin: CGFloat = 20 /// The smallest a single-line row can be. - public static var minHeight: CGFloat { Tuner.shared.value("Rows/Min height", default: CGFloat(60), in: 32...88, unit: .points) } // LOCAL-TUNER + public static let minHeight: CGFloat = 60 /// Space above and below the text. - public static var verticalPadding: CGFloat { Tuner.shared.value("Rows/Vertical padding", default: CGFloat(20), in: 0...28, unit: .points) } // LOCAL-TUNER + public static let verticalPadding: CGFloat = 20 /// Width and height of the leading slot; the title starts after it. - public static var leadingSlot: CGFloat { Tuner.shared.value("Rows/Leading slot", default: CGFloat(30), in: 20...60, unit: .points) } // LOCAL-TUNER + public static let leadingSlot: CGFloat = 30 /// Space between the leading slot, the text and the trailing accessory. - public static var gap: CGFloat { Tuner.shared.value("Rows/Gap", default: CGFloat(12), in: 0...28, unit: .points) } // LOCAL-TUNER + public static let gap: CGFloat = 12 /// Space between the title and the subtitle. - public static var subtitleGap: CGFloat { Tuner.shared.value("Rows/Subtitle gap", default: CGFloat(2), in: 0...12, unit: .points) } // LOCAL-TUNER + public static let subtitleGap: CGFloat = 2 /// How far before the trailing edge the divider stops. - public static var dividerTrailingInset: CGFloat { Tuner.shared.value("Rows/Divider right inset", default: CGFloat(20), in: 0...40, unit: .points) } // LOCAL-TUNER + public static let dividerTrailingInset: CGFloat = 20 /// The size of an icon in the leading slot. - public static var iconSize: CGFloat { Tuner.shared.value("Rows/Icon size", default: CGFloat(20), in: 12...36, unit: .points) } // LOCAL-TUNER + public static let iconSize: CGFloat = 20 /// The opacity of an icon in the leading slot. - public static var iconOpacity: Double { Tuner.shared.value("Rows/Icon opacity", default: 0.5, in: 0...1) } // LOCAL-TUNER + public static let iconOpacity: Double = 0.5 /// The diameter of a flag in the leading slot. - public static var flagSize: CGFloat { Tuner.shared.value("Rows/Flag size", default: CGFloat(28), in: 16...48, unit: .points) } // LOCAL-TUNER + public static let flagSize: CGFloat = 28 /// Space above a section title. - public static var sectionTop: CGFloat { Tuner.shared.value("Rows/Section top", default: CGFloat(24), in: 0...48, unit: .points) } // LOCAL-TUNER + public static let sectionTop: CGFloat = 24 /// Space between a section title and its first row. - public static var sectionBottom: CGFloat { Tuner.shared.value("Rows/Section bottom", default: CGFloat(0), in: 0...24, unit: .points) } // LOCAL-TUNER + public static let sectionBottom: CGFloat = 0 /// The opacity of a section title. - public static var sectionOpacity: Double { Tuner.shared.value("Rows/Section title opacity", default: 0.5, in: 0...1) } // LOCAL-TUNER + public static let sectionOpacity: Double = 0.5 } /// A leading-slot icon at the row's icon size and opacity. diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift index 3363bce13..c0a22bf82 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Controls/SwipeShowcase.swift @@ -14,9 +14,9 @@ import SwiftUI /// 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`. // LOCAL-TUNER + /// Whether the app was launched with `--swipe-sandbox`. @MainActor public static var isRequested: Bool { - ProcessInfo.processInfo.arguments.contains("--swipe-sandbox") // LOCAL-TUNER + ProcessInfo.processInfo.arguments.contains("--swipe-sandbox") } } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/KeyPadView.swift b/FlipcashUI/Sources/FlipcashUI/Views/KeyPadView.swift index 47e2b920a..06f98ea9b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/KeyPadView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/KeyPadView.swift @@ -430,10 +430,10 @@ private struct KeyPadBorderlessStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration .label - .scaleEffect(configuration.isPressed ? Tuner.shared.value("Amount entry/Key pressed scale", default: CGFloat(1.35), in: 1...2) : 1.0, anchor: .center) // LOCAL-TUNER + .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) : restFill) - .foregroundStyle(Color.white.opacity(configuration.isPressed ? Tuner.shared.value("Amount entry/Key pressed opacity", default: 1.0, in: 0...1) : Tuner.shared.value("Amount entry/Key rest opacity", default: 0.8, in: 0...1))) // LOCAL-TUNER + .foregroundStyle(configuration.isPressed ? Color.white : Color.white.opacity(0.8)) .clipShape(.rect(cornerRadius: 5)) } } From a44307a93258aed157e1e702859df3f0c84b49b6 Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Thu, 29 Oct 2026 12:42:38 +0000 Subject: [PATCH 41/42] fix(design): fold the design critique's blocking and should-fix findings Jump-to-bottom rides the bar; the profile's chatter pile opens the full chatter list; accessibility labels, hit areas, Dynamic Type wrapping, iOS 18 fallbacks, tray button stacking and the SE amount inset. --- Flipcash/Core/AppDelegate.swift | 8 +- .../Screens/Conversation/AttachMenu.swift | 2 +- .../Screens/Conversation/AttachSurface.swift | 2 +- .../Conversation/ComposerChipStrip.swift | 3 + .../Conversation/ComposerReplyStrip.swift | 3 + .../Conversation/ConversationBottomBar.swift | 29 +++-- .../Invite Links/GroupInviteScreen.swift | 2 +- .../CurrencyCreationWizardScreen.swift | 1 + .../Currency Info/CurrencyInfoScreen.swift | 1 + .../CurrencySelectionScreen.swift | 2 + .../Screens/Main/Home/TabBarItemStyle.swift | 18 ++- .../Core/Screens/Main/Home/WalletScreen.swift | 2 + .../Core/Screens/Main/Tips/ArchivedRow.swift | 14 +-- .../Core/Screens/Main/Tips/RecipientRow.swift | 50 +++----- .../Main/Tips/TipConversationsScreen.swift | 90 +++++++++++---- .../Core/Screens/Main/You/TipCardExport.swift | 4 +- .../Screens/Profile/ChatProfileScreen.swift | 107 +++++++++++++++-- .../Screens/Profile/E2eeLearnMoreSheet.swift | 8 +- .../Screens/Profile/PublicGroupCards.swift | 108 +++++++++++------- .../Profile/PublicGroupProfileView.swift | 87 +++++++++++++- .../Core/Screens/Settings/SettingsRow.swift | 6 +- .../Settings/SettingsSectionHeader.swift | 4 +- Flipcash/UI/EnterAmountView.swift | 9 +- Flipcash/UI/View+Dialog.swift | 3 +- .../Formatters/DateFormatter.swift | 15 +++ .../FormattedChatListTimeTests.swift | 45 ++++++++ FlipcashTests/Chat/ChatBubbleViewTests.swift | 31 +++-- FlipcashTests/TipCardExportTests.swift | 4 +- .../FlipcashUI/Chat/ChatBackdrop.swift | 41 ++++--- .../FlipcashUI/Chat/ChatBackdropView.swift | 2 +- .../FlipcashUI/Chat/ChatCashCardCell.swift | 34 +----- .../Chat/ChatDateSeparatorCell.swift | 7 +- .../Chat/ChatScreenViewController.swift | 6 +- .../Sources/FlipcashUI/Chat/ChatStyle.swift | 2 - .../FlipcashUI/Chat/JumpToBottomModel.swift | 7 +- .../FlipcashUI/Chat/MessageBackdrop.swift | 10 +- .../FlipcashUI/Chat/ReactionPillRowView.swift | 2 +- .../FlipcashUI/Chat/TranscriptBlur.swift | 12 +- .../Sources/FlipcashUI/Theme/Image+Icon.swift | 3 +- .../Sources/FlipcashUI/Tray/TrayAction.swift | 76 +++++++++++- .../FlipcashUI/Tray/TrayPresentation.swift | 22 +++- .../FlipcashUI/Tray/TraySheetDriver.swift | 15 +++ .../Sources/FlipcashUI/Tray/TrayStack.swift | 54 +++++++-- .../FlipcashUI/Views/Containers/ListRow.swift | 50 +++++--- .../Sources/FlipcashUI/Views/KeyPadView.swift | 17 ++- 45 files changed, 733 insertions(+), 285 deletions(-) create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/FormattedChatListTimeTests.swift diff --git a/Flipcash/Core/AppDelegate.swift b/Flipcash/Core/AppDelegate.swift index a5775913a..b80104ff3 100644 --- a/Flipcash/Core/AppDelegate.swift +++ b/Flipcash/Core/AppDelegate.swift @@ -323,15 +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] = [ - // Apple's own large title: Large Title, 34 pt bold, following Dynamic Type. - .font: UIFont.systemFont(ofSize: UIFont.preferredFont(forTextStyle: .largeTitle).pointSize, weight: .bold), .foregroundColor: UIColor(.textMain), ] let titleAttributes: [NSAttributedString.Key: Any] = [ - // Apple's own inline title: Headline, 17 pt semibold, following Dynamic Type. - .font: UIFont.preferredFont(forTextStyle: .headline), .foregroundColor: UIColor(.textMain), ] @@ -351,7 +349,7 @@ private extension AppDelegate { 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. diff --git a/Flipcash/Core/Screens/Conversation/AttachMenu.swift b/Flipcash/Core/Screens/Conversation/AttachMenu.swift index 26dcd8ed5..e70786831 100644 --- a/Flipcash/Core/Screens/Conversation/AttachMenu.swift +++ b/Flipcash/Core/Screens/Conversation/AttachMenu.swift @@ -63,7 +63,7 @@ struct AttachMenu: View { .iconSize(22) .foregroundStyle(Color.textMain) .frame(width: BarMetrics.accessorySize, height: BarMetrics.accessorySize) - .background(Color.white.opacity(0.08), in: Circle()) + .background(AttachSurfaceLayout.plusFill, in: Circle()) .contentShape(Circle()) } .buttonStyle(.plain) diff --git a/Flipcash/Core/Screens/Conversation/AttachSurface.swift b/Flipcash/Core/Screens/Conversation/AttachSurface.swift index 8d075da45..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. diff --git a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift index e9e9b278d..2dae069b5 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift @@ -296,6 +296,9 @@ private struct ComposerChipRemoveButton: View { 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 faad1c427..432fe2308 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift @@ -73,6 +73,9 @@ struct ComposerReplyStrip: View { 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 60a5d76b4..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,24 +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 resting trailing control's column, resting and typing alike, then moved - // 32pt toward the screen's edge. - let inset = BarMetrics.trailingControlCentreInset(isCompact: true) - - JumpToBottomButtonView.diameter / 2 - 32 + // 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) } } @@ -238,11 +234,15 @@ enum BarMetrics { static let compactInset: CGFloat = 32 /// The gap between the field and the `$` beside it, the same compact and composing. nonisolated static let controlSpacing: CGFloat = 12 - /// The gap between the jump-to-bottom button's bottom edge and the top edge of `$`. - static let jumpButtonGap: CGFloat = 23.6 + /// 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 } 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/Currency Creation/CurrencyCreationWizardScreen.swift b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift index d634c3e08..151acbc83 100644 --- a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationWizardScreen.swift @@ -203,6 +203,7 @@ struct CurrencyCreationWizardScreen: View { .iconSize(22) .foregroundStyle(Color.textMain) } + .accessibilityLabel("Back") } if state.step.showsProgressBar { ToolbarItem(placement: .principal) { diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index 502067ccc..365f359cb 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -351,6 +351,7 @@ private struct CurrencyInfoScreenContent: View { Image.icon(.shareOs) .iconSize(22) } + .accessibilityLabel("Share") } } } diff --git a/Flipcash/Core/Screens/Main/Currency Selection/CurrencySelectionScreen.swift b/Flipcash/Core/Screens/Main/Currency Selection/CurrencySelectionScreen.swift index 5ba8c907d..ff87daee3 100644 --- a/Flipcash/Core/Screens/Main/Currency Selection/CurrencySelectionScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Selection/CurrencySelectionScreen.swift @@ -109,6 +109,7 @@ private struct CurrencyRow: View { Image.icon(.trashCan) } .tint(.bannerError) + .accessibilityLabel("Remove") } } else { CurrencyRowContent(description: description, viewModel: viewModel, isLast: isLast) @@ -139,6 +140,7 @@ private struct CurrencyRowContent: View { } } .font(.appTextMedium) + .accessibilityAddTraits(viewModel.isCurrencyActive(description.currency) ? .isSelected : []) .disabled(viewModel.isSelectionDisabled(for: description.currency)) } } diff --git a/Flipcash/Core/Screens/Main/Home/TabBarItemStyle.swift b/Flipcash/Core/Screens/Main/Home/TabBarItemStyle.swift index 3deb62bca..22f67eb9e 100644 --- a/Flipcash/Core/Screens/Main/Home/TabBarItemStyle.swift +++ b/Flipcash/Core/Screens/Main/Home/TabBarItemStyle.swift @@ -16,6 +16,9 @@ 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. @@ -58,29 +61,32 @@ enum TabBarItemStyle { } } - /// Stacks the glyph over its drawn title and pads the canvas so the bar, + /// 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 pair centres on the glyph. + // 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(glyph.size.width, textSize.width) - let contentHeight = glyph.size.height + titleGap + textSize.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) + 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 + insets.top + glyphHeight + titleGap + y: top + slotSize + titleGap )) } .withRenderingMode(.alwaysOriginal) diff --git a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift index 67f88fc37..1a13e32f9 100644 --- a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift @@ -337,6 +337,7 @@ private struct WalletScreenContent: View { .modifier(WalletCircleGlass()) } .buttonStyle(.plain) + .accessibilityLabel("Back") .accessibilityIdentifier("wallet-open-card-back") Spacer(minLength: 0) @@ -350,6 +351,7 @@ private struct WalletScreenContent: View { .contentShape(Circle()) .modifier(WalletCircleGlass()) } + .accessibilityLabel("Share") .simultaneousGesture(TapGesture().onEnded { Analytics.buttonTapped(name: .shareTokenInfo) }) 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/RecipientRow.swift b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift index 161a7cfcf..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,13 +68,13 @@ 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: 52 + size: RecipientRowMetrics.avatarSize ) VStack(alignment: .leading, spacing: 3) { HStack(alignment: .top, spacing: 6) { @@ -94,7 +94,7 @@ struct RecipientRowBody: View { } } if let subtitle { - Text(Self.styledPreview(subtitle)) + Text(subtitle) .font(.default(size: 14, weight: .regular)) .foregroundStyle(Color.textSecondary) .tracking(0.16) @@ -113,20 +113,14 @@ struct RecipientRowBody: View { } .contentShape(Rectangle()) } +} - /// The preview with a leading "Name:" set apart in bold. A subtitle that already carries its - /// own colour, like a join-request line, is left as its builder styled it. - private static func styledPreview(_ text: AttributedString) -> AttributedString { - guard !text.runs.contains(where: { $0[AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute.self] != nil }) else { return text } - let plain = String(text.characters) - guard let colon = plain.firstIndex(of: ":"), plain.distance(from: plain.startIndex, to: colon) <= 28, - let end = text.characters.index(text.startIndex, offsetBy: plain.distance(from: plain.startIndex, to: colon) + 1, limitedBy: text.endIndex) - else { return text } - var styled = text - styled[text.startIndex.. String { - let calendar = Calendar.current - if calendar.isDateInToday(date) { - return date.formatted(date: .omitted, time: .shortened) - } - if calendar.isDateInYesterday(date) { return "Yesterday" } - let weekAgo = calendar.date(byAdding: .day, value: -6, to: calendar.startOfDay(for: .now))! - return date > weekAgo - ? date.formatted(.dateTime.weekday(.abbreviated)) - : date.formatted(.dateTime.month(.abbreviated).day()) - } } diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index cf88fb8d9..6f88cc4c5 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -49,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) @@ -139,15 +145,17 @@ struct TipConversationsScreen: View { // 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) { - 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) + 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) @@ -155,12 +163,14 @@ struct TipConversationsScreen: View { .navigationTitle("Chat") .toolbarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .principal) { - Text("Chat") - .font(.headline) - .foregroundStyle(Color.textMain) - .opacity(titleCollapsed ? 1 : 0) - .accessibilityHidden(!titleCollapsed) + if drawsContentTitle { + ToolbarItem(placement: .principal) { + Text("Chat") + .font(.headline) + .foregroundStyle(Color.textMain) + .opacity(titleCollapsed ? 1 : 0) + .accessibilityHidden(!titleCollapsed) + } } if showsSearchButton { ToolbarItem(placement: .topBarTrailing) { @@ -446,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 { 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/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 744b2fa65..694e6e0aa 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -39,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] = [:] @@ -156,6 +159,12 @@ struct ChatProfileScreen: View { .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)) @@ -225,14 +234,20 @@ struct ChatProfileScreen: View { } } - /// A public group gets its cover-colored page; a private group keeps the shared profile layout, - /// as does a group not yet loaded, so a private one never paints as public. + /// 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 isPrivate || conversation == nil { - privateScreen + if let conversation { + if conversation.isPrivate { + privateScreen + } else { + publicScreen + } } else { - publicScreen + Background(color: .backgroundMain) { + Color.clear + } } } @@ -354,6 +369,9 @@ struct ChatProfileScreen: View { 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)) @@ -409,7 +427,11 @@ struct ChatProfileScreen: View { } private func publicContent(cover: UIImage?) -> PublicGroupProfileContent { - let named = requirements.flatMap { requirementsNamed ? $0 : nil } + // 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, @@ -418,16 +440,35 @@ struct ChatProfileScreen: View { avatarImageData: sessionContainer.profileAvatars.data(for: .chat(conversationID)), avatarBlurhash: conversation?.picture?.thumbnailBlurhash, cover: cover, - requirements: named.map { - PublicGroupRequirementTile.tiles(for: $0, metadata: mintMetadata, display: ratesController.displayCurrency) - } ?? [], - yourBalance: named.flatMap { yourBalance(in: $0) }, - // Held back with the requirements above it, so a late reveal doesn't push the card down. - pulse: chatters.isEmpty || !requirementsNamed ? nil : PublicGroupPulse(chatters: chatters, now: .now), + 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 + } + if let mint = rule?.mints.first, mint != .usdf, mintMetadata[mint] == nil { + naming.insert(tile.purpose) + } + } + 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 { @@ -448,6 +489,14 @@ struct ChatProfileScreen: View { // 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 { @@ -852,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 a4e7638af..4bf585ca0 100644 --- a/Flipcash/Core/Screens/Profile/E2eeLearnMoreSheet.swift +++ b/Flipcash/Core/Screens/Profile/E2eeLearnMoreSheet.swift @@ -50,7 +50,7 @@ struct E2eeLearnMoreSheet: View { private func section(_ heading: String, items: [String], isEncrypted: Bool) -> some View { VStack(alignment: .leading, spacing: 12) { Text(heading) - .font(.system(size: 11, weight: .bold)) + .font(.default(size: 11, weight: .bold)) .tracking(0.44) .foregroundStyle(Color.white.opacity(0.45)) @@ -58,7 +58,7 @@ struct E2eeLearnMoreSheet: View { HStack(alignment: .firstTextBaseline, spacing: 10) { bullet(isEncrypted: isEncrypted) Text(item) - .font(.system(size: 15)) + .font(.default(size: 15)) .foregroundStyle(Color.white.opacity(0.9)) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) @@ -78,14 +78,14 @@ struct E2eeLearnMoreSheet: View { .alignmentGuide(.firstTextBaseline) { $0[VerticalAlignment.center] + 5 } } - 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." diff --git a/Flipcash/Core/Screens/Profile/PublicGroupCards.swift b/Flipcash/Core/Screens/Profile/PublicGroupCards.swift index ed345217c..ac74ebae8 100644 --- a/Flipcash/Core/Screens/Profile/PublicGroupCards.swift +++ b/Flipcash/Core/Screens/Profile/PublicGroupCards.swift @@ -13,6 +13,8 @@ import FlipcashUI 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 @@ -20,6 +22,8 @@ struct PublicGroupRequirementsView: View { 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") } @@ -61,11 +65,11 @@ struct PublicGroupRequirementsView: View { if isWide { HStack(alignment: .firstTextBaseline, spacing: 8) { amount(tile) - tokenButton(tile.token, purpose: tile.purpose) + tokenButton(tile) } } else { amount(tile) - tokenButton(tile.token, purpose: tile.purpose) + tokenButton(tile) } Text(tile.purpose.caption) .font(.appTextSmall) @@ -81,13 +85,21 @@ struct PublicGroupRequirementsView: View { .foregroundStyle(palette.primaryText) .lineLimit(1) .minimumScaleFactor(0.5) - .accessibilityLabel("\(tile.amount) \(tile.purpose.caption)") + .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(_ token: PublicGroupToken?, purpose: PublicGroupRequirementTile.Purpose) -> some View { - if let token { + private func tokenButton(_ tile: PublicGroupRequirementTile) -> some View { + if let token = tile.token { Button { onToken(token) } label: { @@ -108,8 +120,24 @@ struct PublicGroupRequirementsView: View { .padding(.vertical, -12) } .buttonStyle(PublicGroupDimButtonStyle()) - .accessibilityIdentifier(Self.tokenIdentifier(for: purpose)) + // 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. @@ -122,10 +150,14 @@ struct PublicGroupRequirementsView: View { } private func footer(_ yourBalance: String, style: AnyShapeStyle) -> some View { - HStack { + // 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) @@ -135,8 +167,8 @@ struct PublicGroupRequirementsView: View { } -/// Who has been chatting in a public group lately: a facepile, how many people that is, when the -/// last message was, and the host, who opens their profile. +/// 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 @@ -145,6 +177,8 @@ struct PublicGroupPulseCard: View { 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 @@ -158,6 +192,8 @@ struct PublicGroupPulseCard: View { 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) @@ -182,14 +218,12 @@ struct PublicGroupPulseCard: View { // 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 { - HStack(spacing: -Self.faceOverlap) { - ForEach(Array(pulse.faces.enumerated()), id: \.element.userID) { index, portrait in - // The next face covers the last 10pt of this one, so only the rest is this face's. - let isCovered = index < pulse.faces.count - 1 || pulse.overflow > 0 - Button { - onSelect(portrait.userID) - } label: { + Button(action: onSeeAll) { + HStack(spacing: -Self.faceOverlap) { + ForEach(pulse.faces, id: \.userID) { portrait in ContactAvatarView( id: portrait.userID.uuidString, displayName: portrait.name, @@ -198,25 +232,25 @@ struct PublicGroupPulseCard: View { size: Self.faceSize ) .overlay { Circle().strokeBorder(palette.controlTint, lineWidth: 2) } - // Grown to a 44pt-tall target without moving anything, and cut to the visible part. - .padding(.vertical, (Self.hitHeight - Self.faceSize) / 2) - .contentShape(FaceHitShape(visibleWidth: isCovered ? Self.faceSize - Self.faceOverlap : Self.faceSize)) - .padding(.vertical, -(Self.hitHeight - Self.faceSize) / 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) } } - .buttonStyle(PublicGroupDimButtonStyle()) - .accessibilityLabel(portrait.name) - .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) } - .accessibilityLabel("\(pulse.overflow) more") } + // 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 - @@ -276,13 +310,3 @@ struct PublicGroupPulseCard: View { private static var hitHeight: CGFloat { 44 } private static var hostSize: CGFloat { 28 } } - -/// A face's tap area: the full height of the rect, but only the width the next face doesn't cover. -nonisolated private struct FaceHitShape: Shape { - - let visibleWidth: CGFloat - - func path(in rect: CGRect) -> Path { - Path(CGRect(x: rect.minX, y: rect.minY, width: visibleWidth, height: rect.height)) - } -} diff --git a/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift b/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift index 62df0e298..7dfa274df 100644 --- a/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift +++ b/Flipcash/Core/Screens/Profile/PublicGroupProfileView.swift @@ -27,6 +27,8 @@ struct PublicGroupProfileContent { 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. @@ -52,6 +54,8 @@ struct PublicGroupProfileView 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. @@ -83,6 +87,7 @@ struct PublicGroupProfileView 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 diff --git a/Flipcash/Core/Screens/Settings/SettingsRow.swift b/Flipcash/Core/Screens/Settings/SettingsRow.swift index 79dcb4e6f..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,7 +25,7 @@ struct SettingsRow: View { self.action = action } - init(icon: Icon, title: String, badge: Badge? = nil, insets: EdgeInsets, action: @escaping VoidAction) { + 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) } diff --git a/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift b/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift index ac91643b2..543ac44ff 100644 --- a/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift +++ b/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift @@ -9,9 +9,9 @@ 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 } diff --git a/Flipcash/UI/EnterAmountView.swift b/Flipcash/UI/EnterAmountView.swift index 69b9b51d3..e6b227de4 100644 --- a/Flipcash/UI/EnterAmountView.swift +++ b/Flipcash/UI/EnterAmountView.swift @@ -204,7 +204,7 @@ public struct EnterAmountView: View { ) .padding([.leading, .trailing], -20) .opacity(dimOpacity) - // Locked whenever the thumb is off rest: dragging, sending and the success hold. + // The amount can't change mid-send, so the keypad locks whenever the thumb is off rest. .allowsHitTesting(swipeProgress == 0) if let actionOverride { @@ -220,7 +220,7 @@ public struct EnterAmountView: View { .buttonStyle(AmountActionButtonStyle()) .disabled(!actionEnabled(enteredAmount) || !isActionIdle) // Amount entry v2: 32 from the screen's sides and bottom (the screens already pad 20), - // 22 below the keypad. + // 20 below the keypad. .padding(.top, 20) .modifier(ActionSlotInsets()) } @@ -246,9 +246,12 @@ private enum SwipeDim { /// 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 - 54) + .padding(.bottom, 32 - 20 - bottomInset) } } diff --git a/Flipcash/UI/View+Dialog.swift b/Flipcash/UI/View+Dialog.swift index 3c6383367..b4f05ea6c 100644 --- a/Flipcash/UI/View+Dialog.swift +++ b/Flipcash/UI/View+Dialog.swift @@ -124,8 +124,9 @@ private struct DialogTray: View { .font(.default(size: 14, weight: .medium)) .frame(maxWidth: .infinity, alignment: .leading) } - .contentShape(Rectangle()) .padding(.vertical, 8) + .frame(minHeight: 44) + .contentShape(Rectangle()) } .buttonStyle(.plain) .foregroundStyle(Color.textMain) 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..4b75a59a7 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) } } 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/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdrop.swift index 0f1859412..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. + /// 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 - /// The black that goes with the blur wherever nothing else is darkening the screen. + /// 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 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. + /// 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/ChatCashCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift index 6a11d26a1..19115151e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift @@ -25,8 +25,6 @@ public final class ChatCashCardCell: ChatColumnCell { private let card = BubbleBackgroundView() private let reactionRow = ReactionPillRowView() private let coinIcon = UIImageView() - /// Stands in for the coin icon when the token has none: the dollar glyph on a green disc. - private let cashBadge = ChatCashCardCell.makeCashBadge() private let tokenLabel = UILabel() private let tokenRow = UIStackView() private let captionLabel = UILabel() @@ -35,7 +33,6 @@ public final class ChatCashCardCell: ChatColumnCell { // A card in a group carries its token under the amount and who was paid at its foot, instead // of the token in the header. private let centerCoinIcon = UIImageView() - private let centerCashBadge = ChatCashCardCell.makeCashBadge() private let centerTokenLabel = UILabel() private let centerTokenRow = UIStackView() private let recipientsLabel = UILabel() @@ -72,12 +69,10 @@ public final class ChatCashCardCell: ChatColumnCell { card.translatesAutoresizingMaskIntoConstraints = false badgeSize = configureCircle(coinIcon) - badgeSize += configureCircle(cashBadge) badgeSize += configureCircle(centerCoinIcon) - badgeSize += configureCircle(centerCashBadge) tokenLabel.textColor = ChatStyle.secondaryText - [coinIcon, cashBadge, tokenLabel].forEach(tokenRow.addArrangedSubview) + [coinIcon, tokenLabel].forEach(tokenRow.addArrangedSubview) tokenRow.spacing = 6 tokenRow.alignment = .center tokenRow.translatesAutoresizingMaskIntoConstraints = false @@ -94,7 +89,7 @@ public final class ChatCashCardCell: ChatColumnCell { eachLabel.textColor = ChatStyle.secondaryText centerTokenLabel.textColor = ChatStyle.secondaryText - [centerCoinIcon, centerCashBadge, centerTokenLabel].forEach(centerTokenRow.addArrangedSubview) + [centerCoinIcon, centerTokenLabel].forEach(centerTokenRow.addArrangedSubview) centerTokenRow.spacing = 6 centerTokenRow.alignment = .center @@ -171,24 +166,6 @@ public final class ChatCashCardCell: ChatColumnCell { /// Called when the viewer taps the leading "$" to pay for a reaction. var onReactionBoost: (() -> Void)? - /// The dollar glyph on a disc, standing in for a token with no coin icon; `applyMetrics` fills it. - private static func makeCashBadge() -> UIView { - let badge = UIView() - let glyph = UIImageView(image: UIImage.asset(.dollar).withRenderingMode(.alwaysTemplate)) - glyph.contentMode = .scaleAspectFit - glyph.tintColor = .white - glyph.translatesAutoresizingMaskIntoConstraints = false - badge.addSubview(glyph) - NSLayoutConstraint.activate([ - glyph.centerXAnchor.constraint(equalTo: badge.centerXAnchor), - glyph.centerYAnchor.constraint(equalTo: badge.centerYAnchor), - // The glyph at 14 of the badge's 20, as drawn. - glyph.widthAnchor.constraint(equalTo: badge.widthAnchor, multiplier: 0.7), - glyph.heightAnchor.constraint(equalTo: badge.heightAnchor, multiplier: 0.7), - ]) - return badge - } - /// Clips `view` to a circle and returns its size constraints, which `applyMetrics` sets. private func configureCircle(_ view: UIView) -> [NSLayoutConstraint] { view.contentMode = .scaleAspectFill @@ -213,11 +190,9 @@ public final class ChatCashCardCell: ChatColumnCell { amountLabel.font = .default(size: ChatStyle.cardAmountSize, weight: .semibold) let badge = ChatStyle.cardBadgeSize badgeSize.forEach { $0.constant = badge } - for circle in [coinIcon, cashBadge, centerCoinIcon, centerCashBadge] { + for circle in [coinIcon, centerCoinIcon] { circle.layer.cornerRadius = badge / 2 } - cashBadge.backgroundColor = ChatStyle.cashBadgeFill - centerCashBadge.backgroundColor = ChatStyle.cashBadgeFill headerTop.constant = ChatStyle.cardPaddingTop headerLeading.constant = ChatStyle.cardPaddingX bodyTop.constant = ChatStyle.cardHeaderGap @@ -250,15 +225,14 @@ 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 - cashBadge.isHidden = cash.iconURL != nil coinIcon.kf.setImage(with: cash.iconURL) let isGroup = cash.group != nil tokenRow.isHidden = isGroup centerTokenRow.isHidden = !isGroup centerTokenLabel.text = cash.token centerCoinIcon.isHidden = cash.iconURL == nil - centerCashBadge.isHidden = cash.iconURL != nil if isGroup { centerCoinIcon.kf.setImage(with: cash.iconURL) } recipientsLabel.text = cash.group?.recipients recipientsLabel.isHidden = !isGroup diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatDateSeparatorCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatDateSeparatorCell.swift index 760ab8552..7c299ebdb 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatDateSeparatorCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatDateSeparatorCell.swift @@ -43,14 +43,13 @@ public final class ChatDateSeparatorCell: UICollectionViewCell { let dayFont = UIFont.default(size: 12, weight: .medium) let timeFont = UIFont.default(size: 12, weight: .regular) let color = UIColor.white.withAlphaComponent(0.5) - let gap: CGFloat = 4 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]) } - let day = text[.. Image { - Image(icon.rawValue, bundle: Bundle.module) + 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. diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayAction.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayAction.swift index 6ea359d28..443597bda 100644 --- a/FlipcashUI/Sources/FlipcashUI/Tray/TrayAction.swift +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayAction.swift @@ -9,8 +9,9 @@ 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. The right-hand action -/// keeps its place across a flow's steps, and a change of title crossfades in place. +/// 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. @@ -65,7 +66,8 @@ public struct TrayAction { } } -/// A tray's actions side by side at equal widths, the secondary on the left. +/// 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] @@ -76,7 +78,7 @@ struct TrayActionBar: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { - HStack(spacing: TrayMetrics.actionGap) { + TrayActionLayout(spacing: TrayMetrics.actionGap) { ForEach(TrayAction.ordered(actions), id: \.slot) { action in Button(action: action.action) { label(for: action) @@ -100,7 +102,14 @@ struct TrayActionBar: View { .transition(titleTransition) } case .loading, .success, .successText: - ButtonStateLabel(action.title, state: action.state) + 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) + } } } @@ -121,6 +130,63 @@ struct TrayActionBar: View { } } +/// 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 { diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayPresentation.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayPresentation.swift index e22f214e6..fa3d54a74 100644 --- a/FlipcashUI/Sources/FlipcashUI/Tray/TrayPresentation.swift +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayPresentation.swift @@ -48,6 +48,8 @@ private struct TrayHost: View { @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. @@ -62,19 +64,25 @@ private struct TrayHost: View { Color.clear .overlay(alignment: .bottom) { content - .fixedSize(horizontal: false, vertical: true) + // 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) - .frame(width: 0, height: 0) - .accessibilityHidden(true) + 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) @@ -91,7 +99,8 @@ private struct TrayHost: View { /// /// 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. +/// 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 @@ -115,8 +124,9 @@ struct TrayFill: View { style: .continuous ) shape - .fill(flat.map(AnyShapeStyle.init) ?? AnyShapeStyle(.thinMaterial)) + .fill(flat ?? .clear) .overlay { errorFade.clipShape(shape) } + .background(.thinMaterial, in: shape) .ignoresSafeArea(.container, edges: .bottom) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TraySheetDriver.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TraySheetDriver.swift index e9b7fd4b1..0c78e500c 100644 --- a/FlipcashUI/Sources/FlipcashUI/Tray/TraySheetDriver.swift +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TraySheetDriver.swift @@ -37,6 +37,10 @@ final class TraySheetDriver { 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) { @@ -142,11 +146,19 @@ final class TraySheetDriver { /// 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) { @@ -192,12 +204,15 @@ 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) } diff --git a/FlipcashUI/Sources/FlipcashUI/Tray/TrayStack.swift b/FlipcashUI/Sources/FlipcashUI/Tray/TrayStack.swift index 659b9060a..3ecf949f6 100644 --- a/FlipcashUI/Sources/FlipcashUI/Tray/TrayStack.swift +++ b/FlipcashUI/Sources/FlipcashUI/Tray/TrayStack.swift @@ -68,14 +68,13 @@ public struct TrayStack: View { VStack(alignment: .leading, spacing: 0) { header - ZStack(alignment: .topLeading) { - page(flow.current) - .id(flow.current) - .transition(pageTransition) + // 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) } - .frame(maxWidth: .infinity, alignment: .topLeading) - // Pages fading in and out move with the region as the tray resizes, not on their own. - .geometryGroup() .padding(.top, TrayMetrics.iconToTitle) // VoiceOver reads the title first, then the actions, then the X. .accessibilitySortPriority(3) @@ -98,6 +97,17 @@ public struct TrayStack: View { .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 { @@ -179,7 +189,20 @@ public struct Tray: View { @State private var flow = TrayFlow(root: SingleStep.only) - /// Creates a tray with `content` under the title and message. + /// 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, @@ -214,7 +237,20 @@ public struct Tray: View { extension Tray where Content == EmptyView { - /// Creates a tray of a title, an optional message and its actions. + /// 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, diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/ListRow.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/ListRow.swift index d570fc2c3..64b3e20a1 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/ListRow.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/ListRow.swift @@ -9,8 +9,8 @@ import SwiftUI /// accessory, with a divider that starts at the text and stops at the trailing margin. public struct ListRow: View { - private let title: String - private let subtitle: String? + private let title: Text + private let subtitle: Text? private let tone: ListRowTone private let showsDivider: Bool private let leading: Leading @@ -18,19 +18,41 @@ public struct ListRow: View { private let action: () -> Void @Environment(\.isEnabled) private var isEnabled + @Environment(\.displayScale) private var displayScale - /// Creates a row; pass `EmptyView()` content for a slot the row doesn't use. + /// Creates a row with localized copy; pass `EmptyView()` content for a slot the row doesn't use. public init( - _ title: String, - subtitle: String? = nil, + _ title: LocalizedStringKey, + subtitle: LocalizedStringKey? = nil, tone: ListRowTone = .standard, showsDivider: Bool = true, action: @escaping () -> Void, @ViewBuilder leading: () -> Leading, @ViewBuilder trailing: () -> Trailing ) { - self.title = title - self.subtitle = subtitle + 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() @@ -49,12 +71,11 @@ public struct ListRow: View { } HStack(spacing: ListRowMetrics.gap) { VStack(alignment: .leading, spacing: ListRowMetrics.subtitleGap) { - Text(title) + title .foregroundStyle(titleColor) - .lineLimit(1) .font(.default(size: 16, weight: .medium)) if let subtitle { - Text(subtitle) + subtitle .font(.appTextSmall) .foregroundStyle(Color.textSecondary) .lineLimit(1) @@ -70,7 +91,7 @@ public struct ListRow: View { if showsDivider { Rectangle() .fill(Color.rowSeparator) - .frame(height: 1.0 / 3.0) + .frame(height: 1 / displayScale) .padding(.trailing, ListRowMetrics.dividerTrailingInset) } } @@ -167,10 +188,10 @@ public struct ListRowChevron: View { /// The title above a group of rows. public struct ListSectionTitle: View { - private let title: String + private let title: LocalizedStringKey - /// Creates a section title. - public init(_ title: String) { + /// Creates a section title from localized copy. + public init(_ title: LocalizedStringKey) { self.title = title } @@ -182,6 +203,7 @@ public struct ListSectionTitle: View { .padding(.horizontal, ListRowMetrics.margin) .padding(.top, ListRowMetrics.sectionTop) .padding(.bottom, ListRowMetrics.sectionBottom) + .accessibilityAddTraits(.isHeader) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/KeyPadView.swift b/FlipcashUI/Sources/FlipcashUI/Views/KeyPadView.swift index 06f98ea9b..07012d7a3 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/KeyPadView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/KeyPadView.swift @@ -319,6 +319,14 @@ extension ButtonContent { return .chevronLeftMedium } } + + /// What VoiceOver calls this key. + var accessibilityLabel: LocalizedStringKey { + switch self { + case .chveronLeft: + return "Delete" + } + } } } @@ -372,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 { @@ -387,6 +395,13 @@ private extension KeyPadView { } } .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 - From 633120057acc92a3fe9c7a9410d1b094041ae79d Mon Sep 17 00:00:00 2001 From: aidenjwalker Date: Thu, 29 Oct 2026 12:57:22 +0000 Subject: [PATCH 42/42] test(chat): retarget v1 look assertions to the redesign; shorten the receipt exit fade Chat bubble, quote, media, run-gap and tab-badge tests read the redesign's values from ChatStyle and its metrics. The 4pt run gap brings the next bubble over the receipt line sooner, so its exit fade drops to 0.065 s. --- FlipcashTests/Chat/ChatBubbleViewTests.swift | 12 ++++----- FlipcashTests/Chat/ChatMediaCellTests.swift | 27 ++++++++++++------- FlipcashTests/Chat/ChatQuoteBubbleTests.swift | 12 ++++----- .../Chat/ChatViewControllerTests.swift | 6 +++-- .../Navigation/TabBarBadgedIconTests.swift | 4 +-- .../Sources/FlipcashUI/Chat/ChatMotion.swift | 7 ++--- 6 files changed, 40 insertions(+), 28 deletions(-) diff --git a/FlipcashTests/Chat/ChatBubbleViewTests.swift b/FlipcashTests/Chat/ChatBubbleViewTests.swift index 4b75a59a7..3989501dc 100644 --- a/FlipcashTests/Chat/ChatBubbleViewTests.swift +++ b/FlipcashTests/Chat/ChatBubbleViewTests.swift @@ -202,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") @@ -302,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") @@ -392,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/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/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'.