From 30f333ef90e599a6f6c8b71431775e31cb23cb3f Mon Sep 17 00:00:00 2001 From: senseiGai Date: Sun, 12 Apr 2026 21:40:32 +0500 Subject: [PATCH] =?UTF-8?q?=D0=B1=D0=B5=D0=B9=D0=B4=D0=B6=20=D1=83=D0=BF?= =?UTF-8?q?=D0=BE=D0=BC=D0=B8=D0=BD=D0=B0=D0=BD=D0=B8=D0=B9=20=D0=B2=20?= =?UTF-8?q?=D1=87=D0=B0=D1=82-=D0=BB=D0=B8=D1=81=D1=82=D0=B5,=20=D0=BF?= =?UTF-8?q?=D1=80=D1=8F=D0=BC=D0=B0=D1=8F=20=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=BE=20@mention,=20=D1=82?= =?UTF-8?q?=D0=B0=D0=BF=20=D0=BD=D0=B0=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D0=BA=D1=83=20=E2=86=92=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C,=20RequestChats=20=D0=BD=D0=B0=20UIKit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Rosetta.xcodeproj/project.pbxproj | 117 +++ .../xcschemes/xcschememanagement.plist | 7 +- .../MentionBadgeIcon.imageset/Contents.json | 16 + .../mentionslist.pdf | Bin 0 -> 6748 bytes .../Contents.json | 12 + .../arrowleft.svg | 4 + .../Contents.json | 12 + .../ic_lt_delete.pdf | Bin 0 -> 4503 bytes .../Contents.json | 22 + .../ModernConversationMicButton@2x.png | Bin 0 -> 843 bytes .../ModernConversationMicButton@3x.png | Bin 0 -> 1262 bytes .../Contents.json | 22 + .../RecordVideoIcon@2x.png | Bin 0 -> 651 bytes .../RecordVideoIcon@3x.png | Bin 0 -> 1098 bytes .../Contents.json | 22 + .../InputMicRecordingOverlay@2x.png | Bin 0 -> 627 bytes .../InputMicRecordingOverlay@3x.png | Bin 0 -> 1070 bytes .../Contents.json | 12 + .../pausevoicecideo_30.pdf | 97 ++ .../Contents.json | 22 + .../RecordSendIcon@2x.png | Bin 0 -> 1350 bytes .../RecordSendIcon@3x.png | Bin 0 -> 456 bytes .../VoiceRecordingSend.imageset/Contents.json | 12 + .../VoiceRecordingSend.imageset/send.pdf | Bin 0 -> 4345 bytes .../Contents.json | 12 + .../switchcamera_30.pdf | 188 ++++ .../Contents.json | 22 + .../VideoRecordArrow@2x.png | Bin 0 -> 246 bytes .../VideoRecordArrow@3x.png | Bin 0 -> 301 bytes .../Contents.json | 12 + .../viewonce_30.pdf | 212 +++++ .../1filled.pdf | 85 ++ .../Contents.json | 12 + .../Data/Repositories/MessageRepository.swift | 495 ++++++++-- Rosetta/Core/Layout/LayoutEngine.swift | 58 ++ Rosetta/Core/Layout/MessageCellLayout.swift | 13 +- Rosetta/Core/Services/SessionManager.swift | 5 +- .../Utils/UITestLaunchConfiguration.swift | 15 + .../Components/WaveformView.swift | 13 +- .../Chats/ChatDetail/ChatDetailView.swift | 116 +-- .../ChatDetail/ChatDetailViewModel.swift | 47 +- .../Chats/ChatDetail/ComposerView.swift | 713 ++++++++++++--- .../Chats/ChatDetail/CoreTextLabel.swift | 83 +- .../ChatDetail/MentionAutocompleteView.swift | 280 ++++++ .../Chats/ChatDetail/MessageCellActions.swift | 2 + .../Chats/ChatDetail/MessageCellView.swift | 2 + .../Chats/ChatDetail/NativeMessageCell.swift | 148 ++- .../Chats/ChatDetail/NativeMessageList.swift | 350 ++++++-- .../Chats/ChatDetail/NativeSkeletonView.swift | 283 ++++++ .../Chats/ChatDetail/RecordingLockView.swift | 475 +++++++--- .../Chats/ChatDetail/RecordingMicButton.swift | 65 +- .../ChatDetail/RecordingPreviewPanel.swift | 153 +++- .../ChatDetail/VoiceRecordingAssets.swift | 37 + .../ChatDetail/VoiceRecordingOverlay.swift | 232 ++--- .../ChatDetail/VoiceRecordingPanel.swift | 230 +++-- .../ChatDetail/VoiceRecordingParityMath.swift | 92 ++ .../VoiceRecordingUITestFixtureView.swift | 215 +++++ .../Chats/ChatList/ChatListView.swift | 74 +- .../Features/Chats/ChatList/ChatRowView.swift | 459 ---------- .../Chats/ChatList/RequestChatsView.swift | 211 ++++- .../Chats/ChatList/UIKit/ChatListCell.swift | 42 +- Rosetta/Features/MainTabView.swift | 2 + .../Settings/SettingsViewController.swift | 7 +- .../Lottie/voice_anim_mic_to_video.json | 1 + .../Lottie/voice_anim_playpause.json | 1 + .../Lottie/voice_anim_video_to_mic.json | 1 + Rosetta/Resources/Lottie/voice_bin_blue.json | 1 + Rosetta/Resources/Lottie/voice_bin_red.json | 1 + Rosetta/Resources/Lottie/voice_lock.json | 1 + .../Resources/Lottie/voice_lock_pause.json | 1 + Rosetta/Resources/Lottie/voice_lock_wait.json | 1 + Rosetta/RosettaApp.swift | 72 +- RosettaTests/SlidingWindowTests.swift | 315 +++++++ .../VoiceRecordingParityCheckerTests.swift | 308 +++++++ .../VoiceRecordingParityMathTests.swift | 150 ++++ RosettaUITests/RosettaUITests.swift | 172 ++++ tools/voice_recording_parity_checker.py | 846 ++++++++++++++++++ 77 files changed, 6346 insertions(+), 1362 deletions(-) create mode 100644 Rosetta/Assets.xcassets/MentionBadgeIcon.imageset/Contents.json create mode 100644 Rosetta/Assets.xcassets/MentionBadgeIcon.imageset/mentionslist.pdf create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingCancelArrow.imageset/Contents.json create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingCancelArrow.imageset/arrowleft.svg create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingDelete.imageset/Contents.json create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingDelete.imageset/ic_lt_delete.pdf create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingIconMicrophone.imageset/Contents.json create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingIconMicrophone.imageset/ModernConversationMicButton@2x.png create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingIconMicrophone.imageset/ModernConversationMicButton@3x.png create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingIconVideo.imageset/Contents.json create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingIconVideo.imageset/RecordVideoIcon@2x.png create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingIconVideo.imageset/RecordVideoIcon@3x.png create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingInputMicOverlay.imageset/Contents.json create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingInputMicOverlay.imageset/InputMicRecordingOverlay@2x.png create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingInputMicOverlay.imageset/InputMicRecordingOverlay@3x.png create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingPause.imageset/Contents.json create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingPause.imageset/pausevoicecideo_30.pdf create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingRecordSendIcon.imageset/Contents.json create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingRecordSendIcon.imageset/RecordSendIcon@2x.png create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingRecordSendIcon.imageset/RecordSendIcon@3x.png create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingSend.imageset/Contents.json create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingSend.imageset/send.pdf create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingSwitchCamera.imageset/Contents.json create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingSwitchCamera.imageset/switchcamera_30.pdf create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingVideoRecordArrow.imageset/Contents.json create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingVideoRecordArrow.imageset/VideoRecordArrow@2x.png create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingVideoRecordArrow.imageset/VideoRecordArrow@3x.png create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingViewOnce.imageset/Contents.json create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingViewOnce.imageset/viewonce_30.pdf create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingViewOnceEnabled.imageset/1filled.pdf create mode 100644 Rosetta/Assets.xcassets/VoiceRecordingViewOnceEnabled.imageset/Contents.json create mode 100644 Rosetta/Core/Layout/LayoutEngine.swift create mode 100644 Rosetta/Core/Utils/UITestLaunchConfiguration.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/MentionAutocompleteView.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/NativeSkeletonView.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/VoiceRecordingAssets.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/VoiceRecordingUITestFixtureView.swift delete mode 100644 Rosetta/Features/Chats/ChatList/ChatRowView.swift create mode 100644 Rosetta/Resources/Lottie/voice_anim_mic_to_video.json create mode 100644 Rosetta/Resources/Lottie/voice_anim_playpause.json create mode 100644 Rosetta/Resources/Lottie/voice_anim_video_to_mic.json create mode 100644 Rosetta/Resources/Lottie/voice_bin_blue.json create mode 100644 Rosetta/Resources/Lottie/voice_bin_red.json create mode 100644 Rosetta/Resources/Lottie/voice_lock.json create mode 100644 Rosetta/Resources/Lottie/voice_lock_pause.json create mode 100644 Rosetta/Resources/Lottie/voice_lock_wait.json create mode 100644 RosettaTests/SlidingWindowTests.swift create mode 100644 RosettaTests/VoiceRecordingParityCheckerTests.swift create mode 100644 RosettaTests/VoiceRecordingParityMathTests.swift create mode 100644 RosettaUITests/RosettaUITests.swift create mode 100755 tools/voice_recording_parity_checker.py diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index a3e61fc..541281a 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -16,6 +16,11 @@ 853F29A02F4B63D20092AD05 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29A12F4B63D20092AD05 /* P256K */; }; 85E887F72F6DC9460032774C /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = D1DB00022F8C00010092AD05 /* GRDB */; }; A1B2C3D4E5F60718293A4B5C /* DeliveryReliabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5D /* DeliveryReliabilityTests.swift */; }; + A8D200012F9000010092AD05 /* VoiceRecordingParityMathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8D200112F9000010092AD05 /* VoiceRecordingParityMathTests.swift */; }; + A8D200022F9000010092AD05 /* VoiceRecordingParityCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8D200122F9000010092AD05 /* VoiceRecordingParityCheckerTests.swift */; }; + A8D200032F9000010092AD05 /* RosettaUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8D200132F9000010092AD05 /* RosettaUITests.swift */; }; + A8D200042F9000010092AD05 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 272B862BE4D99E7DD751CC3E /* Foundation.framework */; }; + B1C2D3E4F506172839405162 /* SlidingWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F506172839405163 /* SlidingWindowTests.swift */; }; B7F1C2D34A5E67890ABCDEF1 /* CryptoParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9A1B2C3D4E5F60718293A4B /* CryptoParityTests.swift */; }; C8E2D3F45B6A78901BCDEF12 /* MessageDecodeHardeningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF1B2C3D4E5F60718293A4B /* MessageDecodeHardeningTests.swift */; }; CC5AD9236E3B3BA95A0C29EC /* DBTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D3AF08B754B66DE17AF486D /* DBTestSupport.swift */; }; @@ -109,6 +114,11 @@ 93685A4F330DCD1B63EF121F /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RosettaNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; A1B2C3D4E5F60718293A4B5D /* DeliveryReliabilityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeliveryReliabilityTests.swift; sourceTree = ""; }; + A8D200112F9000010092AD05 /* VoiceRecordingParityMathTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VoiceRecordingParityMathTests.swift; sourceTree = ""; }; + A8D200122F9000010092AD05 /* VoiceRecordingParityCheckerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VoiceRecordingParityCheckerTests.swift; sourceTree = ""; }; + A8D200132F9000010092AD05 /* RosettaUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RosettaUITests.swift; sourceTree = ""; }; + A8D200142F9000010092AD05 /* RosettaUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RosettaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + B1C2D3E4F506172839405163 /* SlidingWindowTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SlidingWindowTests.swift; sourceTree = ""; }; C9FC5C4F7E26FAFEC47C1D51 /* BehaviorParityFixtureTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BehaviorParityFixtureTests.swift; sourceTree = ""; }; D9A1B2C3D4E5F60718293A4B /* CryptoParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CryptoParityTests.swift; sourceTree = ""; }; DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MigrationHarnessTests.swift; sourceTree = ""; }; @@ -154,6 +164,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A8D200322F9000010092AD05 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A8D200042F9000010092AD05 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; B2C595701A2879A2FD49DDEF /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -188,6 +206,9 @@ F0A1B2C3D4E5F60718293A42 /* PushNotificationPacketTests.swift */, 7F4769EEC8ABADB3AA98D3A5 /* SchemaParityTests.swift */, 2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */, + A8D200112F9000010092AD05 /* VoiceRecordingParityMathTests.swift */, + A8D200122F9000010092AD05 /* VoiceRecordingParityCheckerTests.swift */, + B1C2D3E4F506172839405163 /* SlidingWindowTests.swift */, ); path = RosettaTests; sourceTree = ""; @@ -208,6 +229,7 @@ 95676C1A4D239B1FF9E73782 /* Frameworks */, BA35C0165A0371B32DD8B4C0 /* RosettaNotificationService */, 0D5BD0581AA976925F688CDA /* RosettaTests */, + A8D200212F9000010092AD05 /* RosettaUITests */, LA000000C2F8D22220092AD05 /* RosettaLiveActivityWidget */, ); sourceTree = ""; @@ -219,6 +241,7 @@ A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */, LA00000022F8D22220092AD05 /* RosettaLiveActivityWidget.appex */, 75BA8A97FE297E450BB1452E /* RosettaTests.xctest */, + A8D200142F9000010092AD05 /* RosettaUITests.xctest */, ); name = Products; sourceTree = ""; @@ -232,6 +255,14 @@ name = Frameworks; sourceTree = ""; }; + A8D200212F9000010092AD05 /* RosettaUITests */ = { + isa = PBXGroup; + children = ( + A8D200132F9000010092AD05 /* RosettaUITests.swift */, + ); + path = RosettaUITests; + sourceTree = ""; + }; BA35C0165A0371B32DD8B4C0 /* RosettaNotificationService */ = { isa = PBXGroup; children = ( @@ -304,6 +335,24 @@ productReference = 853F29622F4B50410092AD05 /* Rosetta.app */; productType = "com.apple.product-type.application"; }; + A8D200412F9000010092AD05 /* RosettaUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A8D200712F9000010092AD05 /* Build configuration list for PBXNativeTarget "RosettaUITests" */; + buildPhases = ( + A8D200312F9000010092AD05 /* Sources */, + A8D200322F9000010092AD05 /* Frameworks */, + A8D200332F9000010092AD05 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A8D200512F9000010092AD05 /* PBXTargetDependency */, + ); + name = RosettaUITests; + productName = RosettaUITests; + productReference = A8D200142F9000010092AD05 /* RosettaUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; E47730762E9823BA2D02A197 /* RosettaNotificationService */ = { isa = PBXNativeTarget; buildConfigurationList = B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */; @@ -351,6 +400,9 @@ 853F29612F4B50410092AD05 = { CreatedOnToolsVersion = 26.2; }; + A8D200412F9000010092AD05 = { + CreatedOnToolsVersion = 26.2; + }; E47730762E9823BA2D02A197 = { CreatedOnToolsVersion = 26.2; }; @@ -383,6 +435,7 @@ E47730762E9823BA2D02A197 /* RosettaNotificationService */, LA00000012F8D22220092AD05 /* RosettaLiveActivityWidget */, 219188CF4FCBF8E8CF11BEC2 /* RosettaTests */, + A8D200412F9000010092AD05 /* RosettaUITests */, ); }; /* End PBXProject section */ @@ -402,6 +455,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A8D200332F9000010092AD05 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; F9F9B9BDE87DB35631992F35 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -443,6 +503,9 @@ F0B1C2D3E4F5061728394A42 /* PushNotificationPacketTests.swift in Sources */, D60B2E657D691F256B5B7FD4 /* SchemaParityTests.swift in Sources */, 4D5E6F708192A3B4C5D6E7F8 /* SearchParityTests.swift in Sources */, + A8D200012F9000010092AD05 /* VoiceRecordingParityMathTests.swift in Sources */, + A8D200022F9000010092AD05 /* VoiceRecordingParityCheckerTests.swift in Sources */, + B1C2D3E4F506172839405162 /* SlidingWindowTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -454,6 +517,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A8D200312F9000010092AD05 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A8D200032F9000010092AD05 /* RosettaUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; LA00000032F8D22220092AD05 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -478,6 +549,12 @@ target = 853F29612F4B50410092AD05 /* Rosetta */; targetProxy = D1E9D598009C8306B116CA87 /* PBXContainerItemProxy */; }; + A8D200512F9000010092AD05 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = Rosetta; + target = 853F29612F4B50410092AD05 /* Rosetta */; + targetProxy = D1E9D598009C8306B116CA87 /* PBXContainerItemProxy */; + }; LA000000A2F8D22220092AD05 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = LA00000012F8D22220092AD05 /* RosettaLiveActivityWidget */; @@ -759,6 +836,37 @@ }; name = Debug; }; + A8D200612F9000010092AD05 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = QN8Z263QGX; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.devUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = Rosetta; + }; + name = Debug; + }; + A8D200622F9000010092AD05 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = QN8Z263QGX; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.devUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = Rosetta; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; C19929D9466573F31997B2C0 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -861,6 +969,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + A8D200712F9000010092AD05 /* Build configuration list for PBXNativeTarget "RosettaUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A8D200612F9000010092AD05 /* Debug */, + A8D200622F9000010092AD05 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist b/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist index 1fb243c..83ada60 100644 --- a/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,9 +12,14 @@ RosettaLiveActivityWidget.xcscheme_^#shared#^_ orderHint - 1 + 3 RosettaNotificationService.xcscheme_^#shared#^_ + + orderHint + 1 + + RosettaUITests.xcscheme_^#shared#^_ orderHint 2 diff --git a/Rosetta/Assets.xcassets/MentionBadgeIcon.imageset/Contents.json b/Rosetta/Assets.xcassets/MentionBadgeIcon.imageset/Contents.json new file mode 100644 index 0000000..2d74772 --- /dev/null +++ b/Rosetta/Assets.xcassets/MentionBadgeIcon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "mentionslist.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Rosetta/Assets.xcassets/MentionBadgeIcon.imageset/mentionslist.pdf b/Rosetta/Assets.xcassets/MentionBadgeIcon.imageset/mentionslist.pdf new file mode 100644 index 0000000000000000000000000000000000000000..2c54f63da97672d926ee275fce7a22372aac9e1d GIT binary patch literal 6748 zcma)Bc|4R|*d|L-Dv}~<(1I`qGYr|6?2RRB(wG?xV`j|4B(jG@mMqCGk(7wCCrgNk z$etx{kv6+fzGrML@B96}@A+eX&$-Wiu6sH6nRCxI#}Jy@5)er__G7-l!wvySfe7}_ z>>$voQy{Pb2Jc950!afx6F>w!U{k6+3R(#)cRQ5{;xF0im)oJDEbl zAYIu{snCc>lnaIevd1`L@k&BMAS_x*$O398Wk}S(IAL`>Nf=X4BQunz3kr@FQc-3< zMN^=;5?ui_5X}{bCo9mDgpg>0Jw^d|r;EWt%5?WgXa@xoP3;XOV5B7EM4=EBz+ev# z4@nOhNdn0c41vSpU@2*^w6p}EAwj-~ryyw(c(U*s#s-QehKwR%i4-gW52B+Y?Fm#0 z;OcsK8wSv*Eqo%Cgj+*Lqrey(#ubC7kiig12>8zxKona`@MOug7$i{yS1=7p1k|P0 zAq5-~@2Dh1lR#q}kW?H+NLhsL1`q)m zhb2=)Pl4%y;IO}zuSIQ4!;)mZ_$A5IN(2U5g^a-iet6I;6?oeybKs4BXxQloW&QNX$w<@Z*Q7FAQu>)P zqPmhnHXy03tzO%38{6N<8w2qA$wDAWz}}jXFc?4wI9r>zvVY(LTZs|JHkLjI0&C-dX-y1@fTkk>&(;Bl!sIq2Mj)AWD;uHFl8WQ7h<&|04Fr$* za0y06tD34ShH(sqS0vK>eQ_CF(G18Pd%3D@IgR}|wKSQ}sj_PE6DgMHy`07atSl^M z=lplEzmS)|dPppv=?LYcVb?v2{I^eY%YD2*CiQN76bo$=H} za|s+z!ah_6MxM1bKG{HG$1cS8mD;XMpBXOH?guAGyA=$PLU*X0DY#JI7i z#`qCoHA@A$So^?}7`Jw>pIF|o?*q(%<($VI^%ivmjA8KtR`yoPqUv$AhWnc2d6C=k z+qO3ec7brpL#s(_yp0U5d3mm@wJEDEO|9sZ+3VXtsXJ9?BpztqfFIFl#vfGGG`hyx z>C5wq>A5c>&Xr zy=RBujv~fB*8QsW2)G$bmzs*khqSO)(40MGd%{)sy|R0C;vh>5k}q$^p_(|Uee=Fd z!wA}b{yo{Xlf&m|a_3krYO{wO7KOZ+4*O<()2H#VAdcQ(*V|sN3NaQp1BtQk59UgT zrB54YX6jM$xbuTc^yv9jt*I_5&RqOHc93Ii){c_j_#R-L)5k&f6U`sIem z)r7)ZCZ|g^FB*IYosNAJiE5r2gEgORgf`DLS4N*`Jk)rpF+a}kgq@g=`OmnO+uPKh zA^ftcjeBk*(uqb$9i(+suG9U4ISCn-IW&{0#L*Hiqb5X?O=$LwdlZ9-=nj$nT3wli zIp;pYoH^#Csn@P#@wWFAX<9RWK4A@Z)w9y=HHRC(ZA#U@R}`ADWZCD`nCqI0n!q!q zbEdI=@3&oNSz#&Xkrf}e`*xn1o?9+v2em6UrmD`x@Y%k!kcdygGT{8CfQ;~a~NUbu&gx`wnSAvhlJnad!1;VSe1xP+ztg}jhyc~mAV{r zj&-6s89JF3+ZMC*9qT(#WcMm|;_ximlehAga#m*gyKLe1yB!lVYe^TGOS){o#PASgr{-?a?`_6e+?#Jf+xKGaO&s!~o z6p!?o^$gf*T8%w&n#p*hmGAlz_x*9O>(^f6&vAX9A5Io7+kd?PE+jlmCBE_qam%3` zwC_kzYf*oH_Vl&P>~{_g?hV(C=8f#LWsH0jWRhZ%&L~_5!>n8E&OM@aYIO&_GxfWHqz0^;lKHFEre-1K^`9}Iq z`)2DK#D~NcNd(0&fd`+=jz|Y!YA_Ohph$rd@HgW~V}$3c{N{q@fH(_rA0C`Qg<#2v zK)%A`W9Y_mMBA&3S0nqMpFDc9tY)k>qxNIX+u%z<2ZHs5#f4$o10iPDxiueak7U$j zIA`9m@XQ~0N)qprQ~-MtW7~5-o$WTimv405`*QdSUu)tcNJ7H5gcwLF+%L(wl_IYA zSlaz+^XTE>sLVm+$>Z^~qf_1{ z7dobH_l|uVd(Q2pXYc}^9xbKYlbjoynA{id5EmDJ+|WaBC{_6w``#`GRo;8j*7dSu zr%9&v;=Pi*O_Y~;jVqD+>V#C zaNbuW>TcTYQ2Da-R>R{(JiH*b1d5#`z|GXA_S-4 znFwLUc10G&OCAz^RAP6x*ZH4w+wS>y__qZO)J&>at|I1ZKYb|~tkjy<3SPLdz_zgK z3$_1JhGhZGc51vTYdKMW{Mh$ngoXTGpZlGUXB!zu8Jiffz0rkl0+zNf@UNUpK`0|e ztlBN_8kgRi{vr4_{b%i$;6o8khehS5Cvt|zE5(L#-#gcS>_GLPZZ@QJ1$ND@G_Djp zFt2=^Hs92or_)yHEqwvua3=`x-ey?=Ne}f49m((z<-Q> zNJ!n@#+)>v(0r=@Ti7#jtYPP!6^ERQt}|x(>S0CD!s4$Dckuv8Jiq);2k)^{r&`B6}Q_2eqs8{+h)f2{PR<#q`M87A8O9V z-}~I1^dPyswc;(Q_NVrYVfih?r@cSkbh-@ndo_=kPr5eryLyK$D=ZBzf!|J*k^hlg z{u1toVe$?*$@-N;Yme^NsW0I}XR210)ZPc&9Ff=q*)wxQ^pu!)-c-p_Xj6>#-OAaO zknYBp*@9<+PnTwbecBd(%vgSrdGV;@LtoqEbJyqnC%lg1pIUym|AgslzS&fv!o8Z~ zg<1I+uQIq=0pAaS&RfrV&ga+2ITIqD7JK(Y1!R9L+P)~ix$yl+r*hI_<4Vtr=*sJv zn|W;|Z5TU)sv>WW#U&yP<36vr$Nt&8L;L6Z0-rzg!gjfo9Z-o7*5!GPX^n_YOcLX4 z*XWQxr<|-7e=~OV)|pTxHq~Z#2D2kxVS?<5J}s9D;cCOFEIt+<2TRfCT^gQPIzt&+ zundSx(#%t2h&fT@M>$-||GS$Pr(FmRYEa$A#r7`h$Igq}3P5Ld4zNx59D2Q4HS=`R zCUvNyAR$#EWl{#}dS7R6D(KFmAp0tv#wco?Zi=EnR9?e1vnWfDE+kvnsAIS9wGu5D zSzirZCyYvIX{~}VGgxe^j?d`ry_wtF3vLug)G|=?ezJtKwG018vkTp7Kc(sI<7QV1 z+Y;M=z`7b5>PRvM4Wjp|CLr4_Ex9c%dQYrDz!6BMz%ND2+Rsi62#Ahi0yMk7_q+0c ztoc8iS~wlz&t8@>D8Vp-Uv5{>0`+Q{FUHvCMqCr9S=>JfaaknTJJRqz<9Im&xB-RGtu;KiK{J@pB>mDSoKi&d0~*r&ZnepHG$=MqgWo z4?Q1^DH%iUx?JfkjvBf+^?9BeHSTP#i;VrGK6UrT+`AC-9f^=51$`zt_ii*8?LZAx z<99mE-hE$d#H*NHuk`ryq+RN(Wk`l!75Kei?qjDa+3g%km#NCSrdC$)qaX_NY z-uc2)0S8iVoprnK+*#s~ttIx!eQbU~h<4WMn7`X*sLq5_a|yH{>P~&?9@9L#&r>iV znk&0rGCXByk=qe+X*w+YhCfV**()MH*j6|4$4$|b+hBX;whyhkiyS}xu^_$P=86Z; zywmcIvL}iXpAh!9Y7WCfT-h(B^(5rS3~>df z#~*M5E61;muFr`SU`}^8@tJ4cxq8Dno#Uahs`%0PFz|gNB5VIS9mCb`53DcwDY3~2 z@m3vh`^Ui1Rx`Nu=?Fn%>pk7Vb&Mkpz5xaw>J2zOZ|*+kMZ| zQintKA>iAlOisvmDO>GvVhxGkDV;oKP#{1&Aa?W#y2D4YPhNTDP@;=VWS(H?E9Z*l zT#CpFL6BpCsZ@fg-dPyPY_Y?|ydIZzmr;k) zImQ&czgk>Yi3?-GWh5?oz+TvdG-r?aet+r2o zw)z{HSa6%v;$4ISsh&yvs+X7lFGMVT@BT)t2PK;)%(cnav{}r zBxQmBB*%ojh1Rx*7ZSo!-iuY~L3kGZ^d3a1F%-4wQ1GbSSZrE<4bix>T8v&kG|%X~z)2Jf zje2tMMd0!K{kKs^y?a{JdHI~#1(`H4U3NA{4}|K5-lkl+S=zd=k|G_n30@iUMlbgb z-_5pd8qS#`JbFb%;z{{x=JPM)EsJDZX0YK!%Yn=OpB)BwYsc~%`&aV0whF_$iUW;a zhL%0IZTax2rDjEJ+^qh?3kc>`p2OJVMSvTf?z?N#EZJ{{`? z@u*2<5s3P}4}Q@)6Fte|q`u8Afy_nZ8{k!I6TYm;ZJpQ28QraSPot7i%ebLacTOtf zNlM|2(DkIz-Y1pAEi-~ImTQ*5qM1;Ux#Xk6F&;<4Q!X{%IJs-wtG4P*Bu@%Xi%`(_ z$;$h(_Cw7@eqTTSgLR*twF{kLWrGkcxa(dN^|i?4b|sXmfjee|qQK`>Es*ZfX8s@U zT22HSSoALH+}_vw!_r$-vra=O=xSQ=jFHYA)7{InIerWVX*HoO4QKq0C>iLI(%wFN zzhCWGC*MwHdE0H5Uo?m9>SI)1&SE<=WhI{A@G|C#v7a)$+7qieGL`&kWH#yg zWQEnB(xu|&Re0I5x(aGnmSw%}KiJbSPOw%3w_1Aeg=>a716Rf!D+a!W`8RT~_dT5o zxso1dgbhz%pJDB|+U3sB(O06%x!wQWqUtr788{WgT^A&2{byvBq_D_{Cbhz68(DHH1W%|O<$4rWPt76 z7@1N?R1{_15Ar5=1zg*B3Z47}(QoFBt9C1-UtAbHBm@XTr@=P;GzBC8F}Hz^aNNk- zrtLLCdEKTKK(GSh-)OHjpkK+-r)PbQq29_mU~H}5*mNFX#0V%Vogn@H8TgY}TaTJf zzZqd@6n)Iat^NEp1NuFJB1F1mOJ!dgE> z9{@cRum>3WlJ(NsJQ&9j>D58yZbVZ7;W3E}Fe}LB)|4B6euCwLZpK<=V83q8s45IVK8y+J7vYGs6 zX|E?s1NbiiM{oqxe#;UKh>Tqd1c9-y7dQPA1cO3lpdbg1Eg1yJ<=PB>OC~S-A2Jwl zbpFzVL;s~G1Cs^H>rXuy80_DAa#Fyi|D`AQZ#<|h{UHCT2bGoi*IFo43i#^zOHWSv z-)rUn0}n3suh?Kx(m-Kv;!#LQEDl3rr`LzBCUAyCq@^L}q~xR_5Qv<#=-Ta literal 0 HcmV?d00001 diff --git a/Rosetta/Assets.xcassets/VoiceRecordingCancelArrow.imageset/Contents.json b/Rosetta/Assets.xcassets/VoiceRecordingCancelArrow.imageset/Contents.json new file mode 100644 index 0000000..1e1022c --- /dev/null +++ b/Rosetta/Assets.xcassets/VoiceRecordingCancelArrow.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "arrowleft.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rosetta/Assets.xcassets/VoiceRecordingCancelArrow.imageset/arrowleft.svg b/Rosetta/Assets.xcassets/VoiceRecordingCancelArrow.imageset/arrowleft.svg new file mode 100644 index 0000000..b2ed5e1 --- /dev/null +++ b/Rosetta/Assets.xcassets/VoiceRecordingCancelArrow.imageset/arrowleft.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Rosetta/Assets.xcassets/VoiceRecordingDelete.imageset/Contents.json b/Rosetta/Assets.xcassets/VoiceRecordingDelete.imageset/Contents.json new file mode 100644 index 0000000..83f484c --- /dev/null +++ b/Rosetta/Assets.xcassets/VoiceRecordingDelete.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ic_lt_delete.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rosetta/Assets.xcassets/VoiceRecordingDelete.imageset/ic_lt_delete.pdf b/Rosetta/Assets.xcassets/VoiceRecordingDelete.imageset/ic_lt_delete.pdf new file mode 100644 index 0000000000000000000000000000000000000000..6519c5029197380a0314d92c13230dbe5a76ce7e GIT binary patch literal 4503 zcmai&2UHW=7KUjG1O!yNpd(U51p*08ItfGs1w@dJAql-1IvARi3k2!XM0!)2g3<(} zgK!BQ5$R2uh#)=kg5_QBy>G2IYgXoDpFRINGiU#6eS7${;HpBR!VnN&^W2xY<-Cpb zp5|5%6aWK`<~AT%SwKVs<6!Ax1&EVEw*V1UYdaSVmUOj4x?tcKlp`7g$jgJAU9cFW zJ;;L`6Q})<>pVUDaG%*2pn{<$Wdz=paHZ#1UHA$&TqWknrsP`2YU^>VNaQ{>{(N%0 z5t09Vb0GG(_47E+v&cx-V*>thAHoVXLQ{Ec2KySCZg@$}i(QLUF>)}I*vdDw9Ile3 zP#MAmFMtq*qabY9RU5hskIFaLUU-~QORpJ1M18?`1N$E!3XZpMZA&geje_YWy#bSp zXPC6zSs`V;ut}%b8r0RIuQ9V#?x3qOgs=X$wK47bUIEoD5=-nFnd+yqHy$$x?}hj!=^E&19qTt1)U5tMHV=6HdYM3Z0m`hb(7)@D7VK=K8e~)y_OA14qR-5h3n#+ml|6x-V;tN zPpDd)=OknAJ3yMfOEJGxiyBU=BR+ zKKDiV!PDQP*A6H7r1$ zjE&>jhbrLC1A1SINbeo)y2G8g)gAhdL)_V^lpRRQpU!S4#7elrFU#N2NWv-HA>yaD z&-w&a)PGyLV+F!EpnsM*X>{a{BTF1zB_U!*lSf0b-;8tH(+LBJXdx}X|6(x?E&$|* ziEd$>9bK^~j58qdBLH)Ba3Re*14qWYb!4cYdPnoWjHrcmMCo8$03(tpToo_@L|~3~ zj#wQhBnkr@IThwC3P}DG_)SCVH;tcWmj0e4a+9cJ5vQRNRV!4JJt|rqu2%u_o(I@wV zX6Q42T5d2tb-J^8n}VH*oI#)b&6I@&6=DZYF>p{Tmz0!$t+j0HldvDUX7ozE7Qge* zp>H&gB#ies8AxSNLe__$ZH^*Nr>uoT?2zj1X1t(Su&5Yo25n{`&rEZP-B#ZG<(iJf z1pe6!PJ?ca*U%^U8m;kUzNsucRfcgQ-=t;MeQHeYZM9^wx$TSn{X35}$iU1=W?c*X z8Fry!D$E?8LcdUu(Zn@Nzs53z?@C)eA@j(!oDHK2RWr{$Ih<%c_p#k#Z@o-s*Ie{+ zT+@xIKRh%Do8|Nm!EAmdj*nEx`3&rntfE(oxK>vUhUTqy>qjQe`sfhl+P(I5*ZEfn zPn7Ua3?}SW3PoyJ$+mb6Q@G3O;&sW&?T;(YO%-ac0c2l}k(G}>-Z&d za6!^qm1@-Oj&fwM3zWPL3SJbrWg{k0P9{e~oi`_(=;-|rilE>Ibujt3zco$3XC{_RrKeG> z*^EibLriB3X*^*{T2joEEwIoirWnQD9{zmxOAnrAz_jExFSu}4)0jNiOQ-GupvQ^i zxWJQ%6#d8(@5o;Hjg-o8(I6T-C*d9}kEz+-j80vzWR+*AXk?o@^O@{QkXd6ajbb|u zWv{9}&6#z$a+E$V`N{LP(LC7{1uDr=4B2O@BCbW>o25J+l7UQQXKm$}2{FJ=nd>2k z%&&t76H$qJ8f^0t7EBQT-GChP!RNX90)+~0v=S#%B7~at?nd4SJ+;n1!f8FEtrE%u90LBLA&*t~f*MWSwe9ulqo@=ca zh@v&5vJGH7{|Ml<^c=Kt-4JWuKmQl0v&du|dZ_t5h#Tb*j8kE=Wx!IWyk;5A$U6Q1b`V;w{>IuVegUDo$w@ zC@z-v@#UQ7RPN95?5ixlQGWG3<~^7jA(_43AG_GHdG2HFCM|{!V>EYhj%$u{uI%lt z#(~{g$EN3!j9VZ8; zE@uL?Rh%Q)A-N-YE_ngkR&HfT_mawR&T!$SDL6jqQ`c<^zOr!XjH2SzJfU9s?6GHZ zMX3BTeaozyW!iqf*{5%osCZ{PF)@9%~y zr8MMcgk{ttpCGfQP#2y?KkL1eWtb(?DusG=FKk_Y#)!W(G*vA|Eu!Ow&bxu}+jpx8 zC#QI?f0`hQ+6-Y$%X5Or?QT*nq67Fq9e3?<(z?_ z!7+XN@}<(fruWUswk9ZCTd>Uxs;oPAZ2o0YfzX6o{VGl-*`#!gwdQ6GVT)mlXP2Hk?)C%Uuf=-EEA4G6Jg zd+$wEa$%!|(ZYB$52uU)-O>0N!{xoM8^aL`Zq4@1WAEffh?V=78Pyr15#eodd9e$e z1Ul0{5=!nw-zlkx**0IZS<92ZDjzBzA%D8gt?n64WCuy4AiC|1Z47^2*c;y0q>`d4 zr2PoWrc$NSqn4)=qdj%Zm8z+MyrCxGoJgM;ukipYh4KN+El8GDoZf_G&zW8L3bc@a z#j{e^!^gv&O2}LV4r{Kt7TMm{jtGxnRcCcq?Lf4s3?go*c&j9+s3+)OF({i=#T(** zTshWrXIQ*>-LM7QePik&Gb_`enNPwH<40vH@3AlE3OWhJQDzMW_b#11bB=q0=`i$k zW5Jw8@updo`yAD9Y)#01%tGtsg@|>N9n3*ARYKs4z+QMlvq>mQtyqmkZH7Wq>E)#B z3$ri&vjuTcaan?pg5H8Ztpm$<%KMf1kYS~lO(1dtwIWT>EHJG4t@Yq1-P4(hz0n#M zoeeB%5+8ok_<9Y`o5J_NfUeBu#keH`llE4->*W0AZ1Su_CwIq2$G(rag}A$`Wu4o% z;DZM7Pqqw2K|{sHqg6NEO&Y4FzSb>A+5`62Mm~3{c1B>%OgMy%4``=rm)702DqEXx zRjyLjo~jkpMt;zQxW_iK@cV~C3Yb$WIHfJkm z-+j`J#{I+CV)cf8lDUEDNKf&kNzq)_m&`8}HFs9(^pHmP?lM|Oq9e=2@?X?mFR?)k z<}GC}`Ey1l&(|(_SFEPKZyO!1pRo7sCy$_Pp_V_(%=Oy0eOt@6NXfnh`C+QCiLjix zR9$cBYvv1Iy|x}rKm;Lj+8QHW2W19^CG#cYBpM@PTA$C{YjY${eua6TezvFu$*S6+ zk~%NFKHau+ZtV%Z?1*O=?&+ccF>UKrk71Z$#?XhM&{x?K*4`T46N`TRsodS%LJBE| z_c!rdNy%kzE!7Fp-7Jq&q|@Aez9_c_t%rxQgL-+)CG*D3SC}OL@Od{WhPSqqWk0a@8e75rTl<#gBPC-)#BZiBIUC391+T z_G&x6_gR{c`+lvZc}MeS+ur3Qh3n(_b<%r7TlNd+kF)*m)@!+WVdKy06t?_{8T+Ws zw&=wb%^l77v8+9F}5=xasz|5Mk+gc07hU^iWro1eurdN5_yXPBI?#?XA(Rg zAv#IzH$WCWO8>he4C#WjbF}<{@6JED{VyyR75#a~MSCPlQwPw)V4bZU9RN|Vuqaem z95BA9>}qX?21KP)#lR3_K|tFTiFNS;NZkG%^*vk!e?5)pua@B={HhpOR2nP=mIO;c z#G&GbV6Xt`{44XfRg!uEv@43#h<{%RsV|3-`fnG~65vN5|6P+5yy1xc{r_L<_rPK- zKwv-=0s{Z{03^i4A>x1q@G}MlA88*QfWxmCSPDwopufi;k`kmH`Fjiu79%~@KVnj1 zq>cPXOj_)J=>4xeX;QQG_j@6d;D6~sq@bh@>K}Ph(*KT0OOT51SDp(NX>EtWe*fB` zW9>zHf26kPmZKx7YK|&{^di+9EF4KC^kWPs6_G4jQWPzP5rrZ}C8bE^3lTTRK+w_> m7z|n*BZfhv(0>4A$;W8` literal 0 HcmV?d00001 diff --git a/Rosetta/Assets.xcassets/VoiceRecordingIconMicrophone.imageset/Contents.json b/Rosetta/Assets.xcassets/VoiceRecordingIconMicrophone.imageset/Contents.json new file mode 100644 index 0000000..33081c4 --- /dev/null +++ b/Rosetta/Assets.xcassets/VoiceRecordingIconMicrophone.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ModernConversationMicButton@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ModernConversationMicButton@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rosetta/Assets.xcassets/VoiceRecordingIconMicrophone.imageset/ModernConversationMicButton@2x.png b/Rosetta/Assets.xcassets/VoiceRecordingIconMicrophone.imageset/ModernConversationMicButton@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..b407292b296bddb62c1df61f6675934bf88b66ab GIT binary patch literal 843 zcmeAS@N?(olHy`uVBq!ia0vp^HXzKw3=&b&bYNg$WD4*JaRqW`&YamkVP;z&5Y1}s zpV`(w8%VbH&1|1I>;M1%ZT+)=Y@k%zgjpSv=1!fus;hq%P_SppoVNa%Kn1NpDIn^f z*$&p&F%f72$eiA(^V%lO1M2OW2{a9)WzwW=KxIJGIbklC0W^75$AsBHDIjW}wiKus zh&n(9&g|@;-2oJzIJ<4~%+?7YmF-i3HqPpsGpBvh9FUDrOJ{ZT%>g@mRy$BT&>29* zKsQWT*g1DDL`^Hu3b2{26M-6`J_S0U738&92ZT3g0z)CJB*-tAfi-mL&Cfx1{s%I3 zcYUhU=P}qTf0r?=@t9MC+^N&DqOV1M{$u}fUwwViy0{~;g1`T2Z`btn(wP2M<*|_A zm)il+z8}xH-Rg4`=52l`KKX<5#0x7|&A(@{@b7wHcrWsFaSW+oe0!C7n{uE?>qFuH z=X-K**K9NWztAL4dli>h)K9Tf>vnHn^GA@!ZSu_|!`VG-ku$CyE8k`sdFB_Jv)T1j zLFoh6zMXsb?%p@C4LdK`GXG{@B=d%u`_9gX0^i<#np-QM!!~m|^ESRG%bRr#PVMp8 zpzFNtpvq^X!+ktAnKz#~&9vJnMO?>7-y!*EeZ_}Y53U~cn;!i?Vf$%isXY%a%TKD~ zI8oxr;mg}RS3XgoX@gICzd-NHqX9gR7O66N{yelou)))#W17g4&@Z=+E&d+hnlELv zNR`{tbhDYc=7b-Nd@nU*6_${(tFnO#T~HTb9&P^ gJG*ZwsYlWU?&)0VV*hm*m>w8BUHx3vIVCg!0BxO|Jpcdz literal 0 HcmV?d00001 diff --git a/Rosetta/Assets.xcassets/VoiceRecordingIconMicrophone.imageset/ModernConversationMicButton@3x.png b/Rosetta/Assets.xcassets/VoiceRecordingIconMicrophone.imageset/ModernConversationMicButton@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6e0c6307ad1b92c385f509906aacfbde6d6ba92c GIT binary patch literal 1262 zcmXX_2~ZPf6kbJ+h9iu}j13eiFa#oCQ7KkLR6=x74kd!3r2zruiq$|6fy!YvEYWNT z0o@Ql4ks8b4Gg7P1ZyzjaEO4mfmSg_6s<8Tsd1!0|Bapb_wC#7eeZkU`)3x7j@)if zqL3g6vWIuj_kedi@z_{^NanVE1wo70(Yu&o;FE{p3kwT;A&y9-2!@y8IIjY)s!{+D z!K8>#T3=reI3PhpI6SvT3d^XWoD5YL1QDIU;@oLL|DVCkbP1Ev8)1E!i5S2r~(|3G!Vv! zS%e2Lq8!7;;!a|aP>NR80x|%3nDlH_of!m+5XOWuS($kRjuQ^~6@>XS5}A2Nf=_H@ z76b&Mkq`tU!1;d>frgj<;KUW87xO$m4EO%)fA-ds2{E_fQw}?9z`pF3i^KmZpr;`!74>+JAlOq;1}` zTX5*xDb2=1R{Q}=d9>F0-*=QA)9+u5ynoQO6mGMOTldE*eskFm0cujC;LYJf zSI!!4$&A0Wd z$Z&rGk~rk*?{}^fRVTtM__Y=7PB3iEQcGSv_MjyvuUV#4snJ*_e0;)kb&2eQ?cM^J zgR0z-`d7=7XW2Hf^bOkWD?;S%%98W*);B`*&9xns+|kGhIDf_|fs=N0vmt=_$Rph- zh4Y=JLX6!{CJ_8P<0f+6w9}MyVDOu#zGU4!)7gww;ZaeWYsX>&$=&x%Xh!|c;R2YM z=G)z|W>W#LC$BYNo9oR|YALDGr8QzJtCF8L9dvF2qPq^oP-z{dJ>|9pvrt!UU={F}xHn@k*xEw^z0CAg_?c?;vjuB@9E zT&~>?&SJAKtc?h3QBre%vS#EsfB$%{bADE&mml0bU6hEG{d51MKu60L%!o7=E1ze5 zSzRhzbt~SArFw(Dx~nKT;B~ouk{ZS8&neeTIkMMh!#+9om5d$EembiGzK2V1$6~a0 z+mPPi^-AP*!J%~j(u}`NyS4KlDQKp1&b1S;Lf4aMNlUqEIL|crMN>`QX4&EA3N4$8VaZCaZYugJw6)?nz(>)pES zs&9k)hHnLY$^Cx(s`IC|uWUa)q58&wBU}A7{a#M)R!^|WQ7nA(n)OE9dC#JKd5x!6 zHw$l&W4lwTzhVB$^J&#Pc4RZ(t(bM__s7@YKgwRpRIxkoZ-40*IljUjnu*Kyqy?A- zMjQ@f&2@BtD7H>5?|k7BzUE1hc(3TBMmHlb&o+ybKQts_RFyyLf4gq~ YQM%wfXR&_|FcKL&UHx3vIVCg!0HKUFQ~&?~ literal 0 HcmV?d00001 diff --git a/Rosetta/Assets.xcassets/VoiceRecordingIconVideo.imageset/RecordVideoIcon@3x.png b/Rosetta/Assets.xcassets/VoiceRecordingIconVideo.imageset/RecordVideoIcon@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..7e019781f77952be450d1f5534f996a9e951ae8e GIT binary patch literal 1098 zcmeAS@N?(olHy`uVBq!ia0vp^Q6S903?%u>HW~n_CjmYou0XoIe^%R+S#A9@Tl;4I z|NnpL)K%@%mbUc)MP{{5m758P zaCYDHg8Q_Xv3^G$EWCYZ{ksjX z45^5FJ2Nv_$xxuJ+HD4NqJ@osY=D4(rHkH97sv9_^3VVOpAWM-+U~15duBjd_UdOp zXDqvSO6Bg{cWnQdg@lB>jI|@4>32-%{$sQuP4&V7-s{UL0J;lv|o;@nS7cEc048$bVh9s6x&e$DRo zh&%P^b(h#J-{+iPA>%#!Z8cNSI>Vr6mL0x%-5ZtrPb>X$6E?|MG+)H=ko2=&){KYA zc7={U9d=h3bfsQwRqVJKu$XmLL`W60lh=&-*$PL_>fE|l@n);IrbMKUU z$M5R)b`~&j2@a3m)o!lQbKc}$s(<*cqwD1wCNR#Mx?0nADffB>#=q68G<$FEyFR5^nVZ~YXD z7dMh*Zp<^XeW`wReoT<*Z;=`G2foaYoqV)u*OAJr8ihO}Y3$P{+pkbGh-EpyZ&^vW zxlHhUQ<;Auiyt<8eVBXcR!#BosG$D~rd>%;7rT zSZ=Ej)W7vv;jyUbuMylcv-ltL^az~gx0@J$>v5Fn-}1eRn}1%kI3K;E)9Awe7QKvs qUt5}{{>kr{dhNczGfxkXAH1y1`$d)>s$K=mEDWBmelF{r5}E+;G7ZE4 literal 0 HcmV?d00001 diff --git a/Rosetta/Assets.xcassets/VoiceRecordingInputMicOverlay.imageset/Contents.json b/Rosetta/Assets.xcassets/VoiceRecordingInputMicOverlay.imageset/Contents.json new file mode 100644 index 0000000..3fe1a88 --- /dev/null +++ b/Rosetta/Assets.xcassets/VoiceRecordingInputMicOverlay.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "InputMicRecordingOverlay@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "InputMicRecordingOverlay@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rosetta/Assets.xcassets/VoiceRecordingInputMicOverlay.imageset/InputMicRecordingOverlay@2x.png b/Rosetta/Assets.xcassets/VoiceRecordingInputMicOverlay.imageset/InputMicRecordingOverlay@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..55ffca85a91dc4567dda835aef6e01698f767319 GIT binary patch literal 627 zcmV-(0*w8MP)ro?50C^q1wkw3ACz1y=0`|EaQmM=T|-n^L?1gTP`3be>sl~+`hw<@RQC9snU z9;#rXvCwAos%cJh=OEU&!WMs{_yhArG$z^TouCRC83_TN#SyBffw&{_tDMS|< zu*`f6lidBJw>wODE_v`On5pWY0jtS_^I)K=KMV57gNq>yn#%Kfv9yS6_!RvV7Cf6uwt+n;h3Vy}^ z$48*|{y7(~+0zS-;)B0~=}3E9c(%Pg_|x8+2H)a?KZC7^W#ZX}ellX+0-xf8+uYz* zf5bKy8DX+AG6o_e%zPxWUkNwbYT3!~vBt3)=tro%XQQoan5lVh_3k`_V=Am-v4B z;L2dVhsC(tauqi9cn3e&%~N4Tzcrr3fjH(OTtquYt459538OBIC3P0}*}GrC`xXr8 zD1W^s9fmA~35Gp=Nwxv6%!YsaPS=hdyRNWZne{699;C~X^Qr~p+>)+FUapN;wChv} zl$_eNXyipM*@7Sltbw(czgS!c>nS(|Cl6i$D=!buI&>%-q)JsqzX2B>+TyH)wcY># N002ovPDHLkV1hgzD24z4 literal 0 HcmV?d00001 diff --git a/Rosetta/Assets.xcassets/VoiceRecordingInputMicOverlay.imageset/InputMicRecordingOverlay@3x.png b/Rosetta/Assets.xcassets/VoiceRecordingInputMicOverlay.imageset/InputMicRecordingOverlay@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..2940640582d1a3d652c31ef82a9f2e0ce710de2b GIT binary patch literal 1070 zcmV+}1kwA6P)8{Rc+bA~j#jRzlB> zF4~9%3Mt;yO_`+@>90@0xpU^8d+)jT&b{YE^E+^vbHCT`%$b=p^UV+$7#J8B7-)v2 z*B#QRo?57eiufW5@HJ1f^v*i;>GLBKJ=la3Hd&JCN6KwjLnN%C)n9}y48skEVEKpg z7~JByPiQN({2SxwKqYecs^ro*|084AA8lF2Bq~BQDllc!Y4v`@2zI{%bcNOHW@)fX zdOMP!SE|9Th??D!W{;PY)36n67*VySq;WgVJJO9(_bZR8T_JVJhD72f_QcJiG;OU! zoiQxajJRp>j&zME&A8H^aj;A|=^{~DaHT)*V43nZy(8Lajw}5fp}*tV)D#eItYlf} zZyp}OkT#?ZX+zqOHlz({L)wrwqz&o+jPxm2i==#NC(yXHoy7I#Q4FFnO)-#1GE@)w8CKSTEY!vLb1avZfVL{gg{t(?&(M zA|&;CNEb-d@@$ecY2R=vs-H=ryJgQ$BTssq0$pS1s&IK#k@QIU{qBf9x+KkBMb;rC zpY;v=kA>3-MUp8!&_Q_GOgXscvZn62;DwNl=p!Ahi66*PWUaQHt0~9btx20snO}Oqz`y19IxZFBC~iO)-A@=9b06x#}ho!JDsV

W+F zB6YhrJ{-EqjD8_KM78c&*r@ap9X{IJwpSw(UQ^rhu~m`O@J97d9ZRt%+`mDFgGS8f zJ&=L7LT3-9$(ZpzkZ6Rd75>~b;34Mlk!OHA{5!{Y4dJ#r@l5h~KS|Sc1f(E+3H!S% zc?;OTwK{3nVOQ!lgYlo;;I2C;b?sf8PvYtf?NTUU7P)bb`?HdlL$oJ(Q7H{kM_5Ev z%pR)PO2)kutc5XR3;BodOJf*=M%Y5e2&D#m%HzNmcpr~Y7S*bZwf1dx2Pr-IfhK$l z8I3>iX|OtnZ=S{f4zX0lS2V>JJ^ub*Nz-)BL9j#X^yT;>I&*v#lT&wHq$}2^l;?Sn zj$7D3JlJ5%bk7o0JV)!pc1(n~*&^HX%o+-r#*!<-ORQO#ctmaI5m7^z=^|Yqr&|0; z?`e)+S_|Yy@JLaDOPC0*DfzC%Hz!OZQXdec2a@66B-IURL)wrwq!Yw1hm!9}zY?X_ o|IiD>ZB7aVaEA;G3=DjPzhtZmZ> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 8.000000 8.000000 cm +0.000000 0.000000 0.000000 scn +0.038429 12.390181 m +0.000000 12.196983 0.000000 11.964655 0.000000 11.500000 c +0.000000 2.500000 l +0.000000 2.035345 0.000000 1.803018 0.038429 1.609819 c +0.196243 0.816438 0.816438 0.196242 1.609819 0.038429 c +1.803017 0.000000 2.035345 0.000000 2.500000 0.000000 c +2.964655 0.000000 3.196983 0.000000 3.390181 0.038429 c +4.183562 0.196242 4.803757 0.816438 4.961571 1.609819 c +5.000000 1.803018 5.000000 2.035345 5.000000 2.500000 c +5.000000 11.500000 l +5.000000 11.964655 5.000000 12.196983 4.961571 12.390181 c +4.803757 13.183562 4.183562 13.803758 3.390181 13.961571 c +3.196983 14.000000 2.964655 14.000000 2.500000 14.000000 c +2.035345 14.000000 1.803017 14.000000 1.609819 13.961571 c +0.816438 13.803758 0.196243 13.183562 0.038429 12.390181 c +h +9.038429 12.390181 m +9.000000 12.196983 9.000000 11.964655 9.000000 11.500000 c +9.000000 2.500000 l +9.000000 2.035345 9.000000 1.803018 9.038429 1.609819 c +9.196242 0.816438 9.816438 0.196242 10.609819 0.038429 c +10.803018 0.000000 11.035345 0.000000 11.500000 0.000000 c +11.964655 0.000000 12.196982 0.000000 12.390181 0.038429 c +13.183562 0.196242 13.803758 0.816438 13.961571 1.609819 c +14.000000 1.803018 14.000000 2.035345 14.000000 2.500000 c +14.000000 11.500000 l +14.000000 11.964655 14.000000 12.196983 13.961571 12.390181 c +13.803758 13.183562 13.183562 13.803758 12.390181 13.961571 c +12.196982 14.000000 11.964655 14.000000 11.500000 14.000000 c +11.035345 14.000000 10.803018 14.000000 10.609819 13.961571 c +9.816438 13.803758 9.196242 13.183562 9.038429 12.390181 c +h +f* +n +Q + +endstream +endobj + +3 0 obj + 1660 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 30.000000 30.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001750 00000 n +0000001773 00000 n +0000001946 00000 n +0000002020 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2079 +%%EOF \ No newline at end of file diff --git a/Rosetta/Assets.xcassets/VoiceRecordingRecordSendIcon.imageset/Contents.json b/Rosetta/Assets.xcassets/VoiceRecordingRecordSendIcon.imageset/Contents.json new file mode 100644 index 0000000..2d3148c --- /dev/null +++ b/Rosetta/Assets.xcassets/VoiceRecordingRecordSendIcon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "RecordSendIcon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "RecordSendIcon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rosetta/Assets.xcassets/VoiceRecordingRecordSendIcon.imageset/RecordSendIcon@2x.png b/Rosetta/Assets.xcassets/VoiceRecordingRecordSendIcon.imageset/RecordSendIcon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..6c5628c51fdbb349bc3cb4a40b4821df34680c6e GIT binary patch literal 1350 zcmeAS@N?(olHy`uVBq!ia0vp^Wulw^r(L`iUdT1k0gQ7VIDN`6wR zf@f}GdTLN=VoGJ<$y6H#24;=Skcg59UmvUF{9L_6kQ%*;+ybC(1_m4Zih{)C?9>v4 zq}24xJX@vryZ0+8WTx0Eg`4^s_!c;)W@LI)6{QAO`Gq7`WhYyvDB0U7*i={n4aiL` zNmQuF&B-gas<2f8n`;GRgM{^!6u?SKvTcwn`Gt*5rG`3JMx70H< zwX`rY(NQomFf`LQu+%p+(KRr%GO)BVFjRm7C7^9ZDQQ+gE^bh}fIM5JjFOT9D}DX) z@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7^KE~&-IMVSR9nfZANAQKal z@=Hr>m4GgVcp(sS9KFoU6f0vx11A?F3nN1_3u8k=S92#9OGig@ zBUf`*Cqn~cCl{Drm;B_?+|;}hnBEkGUSphkK?x$a0BEyIYEfocYKmJ?ey#%8<5rni z++ynLWME<9>||(WZh_q`5WOi_+yd3>j8m^Z&@uX;=tYWdm=G`xftc{b3*^9)e`+2u z%@+X^_nfJ2#taOMTRdGHLn>~)xoPix*g@vl$CznKFQf}T^K$Tsy0hPJC@B;F!fWwE zNhU_NmbodRv_N1^>E(rw{%<@N)p_2rg-`M1-M{aO#OF2Z>z;ku$FI_A7Ack?;%ex0 zI&wangY^q>+b?Y9?Z4z-KGq6&`8X@!<)f$tmkvuVxO6ab!K34r3mzTiT=3{H=b}dk zD;-MvKX<(16K4>W6BiKWOYiuVWARUn^_;-ho^8_4?kwEvS17hZbjw8+?d$K@(wnu; zXS2R$N$)mlWj(rcK~92W!8(oT8R2tSPO&~?Rc|e7?R=yXC}bcw$Ha^4SJFa!@zUzI_*K%k4rC#t~kceIAyyd};jo}eHk84>u zZhs+?+xmn1p!J%(Cz1Z_i7kR|61a(thwe%D)vIs@d+mLc8f5&`>gH)jdn?z?UCU?w zn)zMfWwvZXX-Vh43oNn=Rqy}S%DHq}zu%sbjp6;?+7h#k+RH&Do2RRv%Q~loCIERU B;lBU? literal 0 HcmV?d00001 diff --git a/Rosetta/Assets.xcassets/VoiceRecordingRecordSendIcon.imageset/RecordSendIcon@3x.png b/Rosetta/Assets.xcassets/VoiceRecordingRecordSendIcon.imageset/RecordSendIcon@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..b1457fe819542ed8451fd56eba3f56502bb66af2 GIT binary patch literal 456 zcmeAS@N?(olHy`uVBq!ia0vp^fk2$e!3-ozE9{Q|DYF2d5LY1mpLFms`Pd(zPQj8O zzhH)Ya?f`sZIwR3xF=Qe(`Sj4{uleZ85kH1JY5_^D&pRr@ytB{{dCVPxVU;jw!1*ZujI9w+s%A+CHLH4K8>fzhrv;;K|$DIf~CSq3l1Ye6-jo_ zv?i6u3MYLgq{yhuL`c<=|6EMeEQy3{Np`_i$i?~8(~`>*+nl>d5PocM3?3s3%_FHe36 zF3C6%I%!(Jf~&LMq+N`PR-3#JHwegDF1ZOJomF1APTFk3Aa!w~^DBnl7bo;G89NlD ziY6}k{7GWAW`6@gy2z3)u9^is7>bLroT ursM18TGq2abl%6}GH)E>UonBzTGI+ZBxvXu#ag?^M3`ABDC?3RIKnO?SKwxbg$qv0I z{*oAC@h)UnkUR{o_zN)xL4QUek%?He2auXv5{pCa?h$Ap_$)-q0036?)t8N8*@`a9 z+`AOyp1v!J4ZU^8u3EdDCc!&(v^j0nxpfYBk}aKf>@;C;b8(s4Ug8D5R8qPoELFg9 zko?7{^Pxq-KuvCCV8EB;zRxKuX&Y>}@EqE5#LO+v(;e(j`)WJw5n1Hi+7yEDDigvD zzUK;{`-IFAeD)PpMlcc@_*S-)O`i!b2<)<|+CZ&T?V+yn$`&OH)-xd&29N3z0ZXs5I8A z-G3b?6!D$G8CtpXsLQD(eGwCQoQM_1N>x%bmTt7WNl6IJgJ;c2Mdz^;wxm+IJ8KwVLt!)U}PTbH5D|XybSh1aJ@H@Z_GX z6)B1cbq79bKU%?-#zPru7O=2HcGJXFTrXVTc-tb*%@U$6|%xpJPj$FRZkcyC~ z^rI#R+btFL8tFmXUc0Kz0Tl%A-WUj#)sD+JeJ`oKe^h03=iHuU>C2beYva+$a_)wl zM@6HfITQ@vDS8ow$K{Ot3f!L-D*$kN4C(kib;u!Fki zVa{FD(2~%@ntW-Io)L1o@rR^#MP8A8hQb{7k}ka7txhxa*Z0>te4C8CasT5H0B&2( zb#fv(MnQ8~-~4b=l+}o9Q7A3RKbe87K7COu4aF-tdFR-*_@bmUdga;|48Mc)Vv27$ zHcyYho6Q9jed;6Y2lGt-G5%61typ_**2X5D#6HXMuUIJQ@(GovWm_6yvNZ(g2q{c@&Id-oG< zYruqrHQ3{n6|%=1VTiCP*ZfW^GUdv|WY?P`%_YwuGUT&oaKWG0Z*r}2RSGB^AGM!4 zM@h@Al(L7}mzYr0=cD)Ay|R%(dK6ek^omiQROBoZ=a0|Rzr=M)2zCf4gGXdr?5WN} z9DJ5^Q!z`&suz&Rr2TIatrM#gafv)IFwWR5$F=;txLb@X#ns65Y>8b7SFd=l;1m0{ zm@&~gCtsneB-PA}wBanV_MDEf&V+VYhh17yMql=d{>|)$w-Ilo+s8YSI=bH3=hkS{ zoZAQn)CJ*gIe6CIYZ$CbyNxaz+D;dsdx@Eem9{Hq_-2H^*yE@;OdfU{t~_eF`vEel z=}>gvgU2~{ahGsmV=~b0mH|Tt@5a2=1CEofvI+(8gP2=FqSp;|PdYx+e2%%Qb3-TM zxwJt*^Rect*pspL`Cs#*^4;<-=f~u&<&*OI@-~VfB}2WY-3&WztC3>Y+4Ofh1s<>5 zzn6x4eCsipi0z$tJYKYd`Q`vFB0Ns@Z1cI|l}+A<>4?{8(fnklcReF(*tx;G;imC| zF(wOY9Hr`@L;Z;RC^_Rk-o`7{* z#Jjz5&ats^M~!?=y-!sY2kz)}Ru_6GZ~a_hM2~nDbG%1ZsEPb4uW|L(-dGCU24WK* zK*}HMwL70$Fl)6q>^tW>x%`MvS-2FxuV=Kd&~5h#XMcF9 z<5FuFqkde?as#zM|2kRLU!}946TaxMxMh)dlG5jwZdpjRn;xysTuC$-75^?ySS;uX z%zs-t*9iCkXaeARqKe*yEb}ZLShY<-siKCg+AVWT${)_G9U4emr%#3p-*6R`RGJyf z9vrQbdY}8rjsB&>vD-1BA*D04b8fY9weXR7Rq4HjX0IWyv8#i-`zhNg6bYEIKqq}Z zYWeEf_ALCy$o$#!92sf3CYgnU3Q97FwGW@;Q+ZlBlgE^sPxMVi)PZA+-ac4$&c5I= zYighw@dQ>>@-2Cz{1NvP2izf#jy*cM~?z_JUPodaT$A$WeZEx=4?nVm^gKv1} zkL|DC<28m~oS7VG2E-Mdn=U8jG-Q0PH;a2X(Utt@Zslv*0Fl0~J8M*#WK`3$_U`TZ z_kI4&L+0Zi4SgN~5i82e{mbBi=?cE-OI{^#qS5-swOWru6EB#uD+Q~$ZIWY#o8NIKMCMlTK0rvy%$vYF?9>h?GyPT z6B9yk-t!fLYB$7?0&lRdZ^R@fOYLme>QJ&(y{i$I5VMhV`s&Fo>doG4rU(5a4gnJb zTl|U;8iT1^ffhdd%AL-gZ+L3y24ic%v7!9rIj2bwbI)UIl?b^@-@W{I+Fx;p8P>M$ z+A@55ZQBL*LXerh;FhsK;Wr!Avo+&3sqblp@u@N?<4~AKzW$C>(1YSIOtpUFZOSub zii*hXyoT$hw=F?PNS2s!$M&G>Wjb(@frisFF~^ja*VPbCHVgLJxb&W$gxsDUaN}_l zosE2Iohx!nyBLeJ{$hli>{a16{ro{6H(}Us*aiefYH4YrNmwTki>sai+5M)z@ZVZ2 zqO3)5ClJqiq8+h6c(WD+#PV{6Np}C_yNZ9={J*4DiRI#NE(`FuV{}bKZ%622z_oM2 zwz;dV*5jNn^r#NB!rIaGLGTJv-c>Z*RZU{c>d_AU4je_vD>a2zUOlcf_OJ{hy#$sRoRC z(yZmmTE+d?FSo^>jzqydl&VCJ@kDg%^LzJZphGerocLP&>{k8oy|l>ft>x7ncB4 z94=T71tnxWGYju`{gNU1d4#GmZfV*jQy5e~3fW<=Hn+5}N( zA`=5aF$nWd^FxCF2ZJ-S`WFU~Xa4T~#^jYC|Iw3Ig8mOZMdqgd zWsCT)KPa4;-@o*raQXk(LKP9r?*8o$s-*ZwE@UDa=Z+-;S%(d&&FtMVd3lJfoT5Aw wstA>n{P*XNo`TO12+W55?g#iR&KJun9zvm8KXz?qdH?_b literal 0 HcmV?d00001 diff --git a/Rosetta/Assets.xcassets/VoiceRecordingSwitchCamera.imageset/Contents.json b/Rosetta/Assets.xcassets/VoiceRecordingSwitchCamera.imageset/Contents.json new file mode 100644 index 0000000..c55a510 --- /dev/null +++ b/Rosetta/Assets.xcassets/VoiceRecordingSwitchCamera.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "switchcamera_30.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rosetta/Assets.xcassets/VoiceRecordingSwitchCamera.imageset/switchcamera_30.pdf b/Rosetta/Assets.xcassets/VoiceRecordingSwitchCamera.imageset/switchcamera_30.pdf new file mode 100644 index 0000000..aa86094 --- /dev/null +++ b/Rosetta/Assets.xcassets/VoiceRecordingSwitchCamera.imageset/switchcamera_30.pdf @@ -0,0 +1,188 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 3.084961 5.584656 cm +0.000000 0.000000 0.000000 scn +9.490515 20.080341 m +9.422689 20.080362 l +8.999260 20.080563 8.679590 20.080715 8.371326 20.006708 c +8.099400 19.941423 7.839443 19.833746 7.600999 19.687628 c +7.330693 19.521984 7.104758 19.295835 6.805490 18.996283 c +6.757546 18.948309 l +5.959852 18.150616 l +5.772345 17.963108 5.716480 17.908821 5.660614 17.864992 c +5.468593 17.714350 5.238950 17.619228 4.996650 17.589970 c +4.926156 17.581457 4.848266 17.580341 4.583089 17.580341 c +4.484859 17.580357 l +3.725908 17.580544 3.223788 17.580666 2.792615 17.474993 c +1.466152 17.149897 0.430476 16.114222 0.105380 14.787757 c +-0.000294 14.356585 -0.000171 13.854465 0.000015 13.095512 c +0.000031 12.997284 l +0.000031 5.465342 l +0.000031 5.436520 l +0.000025 4.620880 0.000020 3.968216 0.043112 3.440796 c +0.087345 2.899416 0.180274 2.431705 0.399492 2.001467 c +0.750868 1.311853 1.311542 0.751179 2.001156 0.399803 c +2.431395 0.180586 2.899104 0.087656 3.440485 0.043423 c +3.967894 0.000332 4.620543 0.000336 5.436161 0.000341 c +5.436225 0.000341 l +5.465031 0.000341 l +18.365032 0.000341 l +18.393837 0.000341 l +18.393900 0.000341 l +19.209520 0.000336 19.862169 0.000332 20.389578 0.043423 c +20.930958 0.087656 21.398668 0.180586 21.828907 0.399803 c +22.518520 0.751179 23.079195 1.311853 23.430571 2.001467 c +23.649788 2.431705 23.742718 2.899416 23.786951 3.440796 c +23.830042 3.968204 23.830038 4.620852 23.830032 5.436470 c +23.830032 5.436536 l +23.830032 5.465342 l +23.830032 12.997283 l +23.830048 13.095503 l +23.830235 13.854461 23.830357 14.356583 23.724682 14.787757 c +23.399588 16.114222 22.363911 17.149897 21.037447 17.474993 c +20.606276 17.580666 20.104155 17.580544 19.345201 17.580357 c +19.246973 17.580341 l +18.981796 17.580341 18.903906 17.581457 18.833412 17.589970 c +18.591112 17.619228 18.361469 17.714350 18.169449 17.864992 c +18.113583 17.908821 18.057718 17.963108 17.870209 18.150616 c +17.072515 18.948311 l +17.024576 18.996279 l +16.725307 19.295834 16.499371 19.521982 16.229063 19.687628 c +15.990620 19.833746 15.730661 19.941423 15.458736 20.006708 c +15.150473 20.080715 14.830803 20.080563 14.407373 20.080362 c +14.339548 20.080341 l +9.490515 20.080341 l +h +8.681808 18.713455 m +8.817650 18.746067 8.969681 18.750341 9.490515 18.750341 c +14.339548 18.750341 l +14.860382 18.750341 15.012413 18.746067 15.148252 18.713455 c +15.284472 18.680752 15.414694 18.626812 15.534140 18.553616 c +15.653254 18.480623 15.763777 18.376143 16.132063 18.007858 c +16.929756 17.210163 l +16.954220 17.185692 l +16.954237 17.185675 l +17.106804 17.033035 17.221756 16.918030 17.348524 16.818577 c +17.731848 16.517857 18.190273 16.327971 18.673967 16.269562 c +18.833941 16.250244 18.996555 16.250284 19.212389 16.250336 c +19.246973 16.250341 l +20.139725 16.250341 20.466656 16.245523 20.720854 16.183224 c +21.565954 15.976102 22.225792 15.316265 22.432913 14.471165 c +22.495213 14.216966 22.500031 13.890034 22.500031 12.997283 c +22.500031 5.465342 l +22.500031 4.614289 22.499514 4.015995 22.461367 3.549101 c +22.423855 3.089968 22.353294 2.816771 22.245531 2.605274 c +22.021667 2.165916 21.664457 1.808706 21.225100 1.584843 c +21.013603 1.477079 20.740406 1.406519 20.281273 1.369007 c +19.814379 1.330860 19.216084 1.330343 18.365032 1.330343 c +5.465031 1.330343 l +4.613979 1.330343 4.015684 1.330860 3.548789 1.369007 c +3.089657 1.406519 2.816460 1.477079 2.604963 1.584843 c +2.165605 1.808706 1.808395 2.165916 1.584531 2.605274 c +1.476768 2.816771 1.406208 3.089968 1.368695 3.549101 c +1.330548 4.015995 1.330031 4.614290 1.330031 5.465342 c +1.330031 12.997284 l +1.330031 13.890034 1.334849 14.216966 1.397150 14.471165 c +1.604271 15.316265 2.264108 15.976102 3.109208 16.183224 c +3.363407 16.245523 3.690338 16.250341 4.583089 16.250341 c +4.617674 16.250336 l +4.617689 16.250336 l +4.833515 16.250284 4.996125 16.250244 5.156096 16.269562 c +5.639788 16.327971 6.098214 16.517857 6.481537 16.818577 c +6.608315 16.918037 6.723272 17.033049 6.875851 17.185703 c +6.900304 17.210163 l +7.697999 18.007858 l +8.066284 18.376143 8.176808 18.480623 8.295923 18.553616 c +8.415368 18.626812 8.545589 18.680752 8.681808 18.713455 c +h +8.819138 12.449797 m +9.606685 13.253181 10.702194 13.750379 11.915030 13.750379 c +13.964883 13.750379 15.683014 12.327185 16.134258 10.415339 c +14.848875 10.415339 l +14.636918 10.415339 14.521129 10.168130 14.656816 10.005297 c +16.622980 7.645809 l +16.722927 7.525869 16.907139 7.525866 17.007090 7.645802 c +18.973408 10.005290 l +19.109104 10.168120 18.993317 10.415339 18.781355 10.415339 c +17.491955 10.415339 l +17.019377 13.067384 14.702655 15.080379 11.915030 15.080379 c +10.330412 15.080379 8.896755 14.428887 7.869370 13.380838 c +7.612269 13.118567 7.616461 12.697534 7.878733 12.440434 c +8.141004 12.183333 8.562037 12.187525 8.819138 12.449797 c +h +6.338119 8.415344 m +5.048842 8.415344 l +4.836884 8.415344 4.721095 8.662557 4.856786 8.825389 c +6.822981 11.184870 l +6.922931 11.304811 7.107148 11.304810 7.207097 11.184867 c +9.173254 8.825387 l +9.308942 8.662554 9.193151 8.415344 8.981194 8.415344 c +7.695821 8.415344 l +8.147092 6.503536 9.865205 5.080379 11.915030 5.080379 c +13.096758 5.080379 14.166923 5.552347 14.949532 6.319569 c +15.211796 6.576675 15.632830 6.572495 15.889937 6.310231 c +16.147045 6.047967 16.142864 5.626933 15.880600 5.369825 c +14.859452 4.368757 13.458792 3.750378 11.915030 3.750378 c +9.127432 3.750378 6.810725 5.763336 6.338119 8.415344 c +h +f* +n +Q + +endstream +endobj + +3 0 obj + 5480 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 30.000000 30.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000005570 00000 n +0000005593 00000 n +0000005766 00000 n +0000005840 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +5899 +%%EOF \ No newline at end of file diff --git a/Rosetta/Assets.xcassets/VoiceRecordingVideoRecordArrow.imageset/Contents.json b/Rosetta/Assets.xcassets/VoiceRecordingVideoRecordArrow.imageset/Contents.json new file mode 100644 index 0000000..7429672 --- /dev/null +++ b/Rosetta/Assets.xcassets/VoiceRecordingVideoRecordArrow.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "VideoRecordArrow@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "VideoRecordArrow@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rosetta/Assets.xcassets/VoiceRecordingVideoRecordArrow.imageset/VideoRecordArrow@2x.png b/Rosetta/Assets.xcassets/VoiceRecordingVideoRecordArrow.imageset/VideoRecordArrow@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ad187da93b6a5ebf4760a6afce5d91dce12da0d7 GIT binary patch literal 246 zcmeAS@N?(olHy`uVBq!ia0vp^DnKmA!VDzcX9{lxQn~>?A+A9B|NsA|PG2~E=EApc z-#2dBId%GiCr_WxU$}DGjD#@9rVltBovi+uCQF=*)FQaf0Zlw&=P@L557q$JrQ`dP-N9g8-b%7yZH@bn7bD5 rFqr;O%0>E+{7#-d{DHOF<%|q3gEVcL!tP!GTFT(*>gTe~DWM4fyclJ< literal 0 HcmV?d00001 diff --git a/Rosetta/Assets.xcassets/VoiceRecordingVideoRecordArrow.imageset/VideoRecordArrow@3x.png b/Rosetta/Assets.xcassets/VoiceRecordingVideoRecordArrow.imageset/VideoRecordArrow@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..31a6e5fd3215ab2a65ad2d57c21fafa4f7b8a1f5 GIT binary patch literal 301 zcmeAS@N?(olHy`uVBq!ia0vp^W$b-PLP1PoKGP?b@xMKYz`+y72{2n_x+hUoZoc z#9pTt{~P{37Z<#fR-ejm_rcvAC^^H^#WAFUaqeYzK4wJ*=fKO=hyLo%j?-0a|MQi{ zM(Ubmv;C%XkvY$udk!Y+PcGUsW%5ntWA5kj80Q;?Fw7SWVJ`NXxlk&?<^i{(>V?pl zRecM%GAbl~3xAMwWCdbV5DstPJF?dj}&Cc&Hj;}hqy9lzs00^P{q>FVdQ&MBb@03gzRZ~y=R literal 0 HcmV?d00001 diff --git a/Rosetta/Assets.xcassets/VoiceRecordingViewOnce.imageset/Contents.json b/Rosetta/Assets.xcassets/VoiceRecordingViewOnce.imageset/Contents.json new file mode 100644 index 0000000..9ca42ca --- /dev/null +++ b/Rosetta/Assets.xcassets/VoiceRecordingViewOnce.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "viewonce_30.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rosetta/Assets.xcassets/VoiceRecordingViewOnce.imageset/viewonce_30.pdf b/Rosetta/Assets.xcassets/VoiceRecordingViewOnce.imageset/viewonce_30.pdf new file mode 100644 index 0000000..cc28878 --- /dev/null +++ b/Rosetta/Assets.xcassets/VoiceRecordingViewOnce.imageset/viewonce_30.pdf @@ -0,0 +1,212 @@ +%PDF-1.7 + +1 0 obj + << /ExtGState << /E4 << /ca 0.200000 >> + /E2 << /ca 0.600000 >> + /E3 << /ca 0.400000 >> + /E1 << /ca 0.800000 >> + >> >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 5.000000 3.670105 cm +0.000000 0.000000 0.000000 scn +10.000000 0.664955 m +10.367270 0.664955 10.665000 0.962687 10.665000 1.329956 c +10.665000 1.697226 10.367270 1.994957 10.000000 1.994957 c +10.000000 0.664955 l +h +10.000000 20.664955 m +10.367270 20.664955 10.665000 20.962687 10.665000 21.329956 c +10.665000 21.697226 10.367270 21.994957 10.000000 21.994957 c +10.000000 20.664955 l +h +10.000000 1.994957 m +4.844422 1.994957 0.665000 6.174377 0.665000 11.329956 c +-0.665000 11.329956 l +-0.665000 5.439838 4.109883 0.664955 10.000000 0.664955 c +10.000000 1.994957 l +h +0.665000 11.329956 m +0.665000 16.485535 4.844422 20.664955 10.000000 20.664955 c +10.000000 21.994957 l +4.109883 21.994957 -0.665000 17.220074 -0.665000 11.329956 c +0.665000 11.329956 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 13.462646 4.335022 cm +0.000000 0.000000 0.000000 scn +0.607792 21.290033 m +0.914249 21.316515 1.224271 21.330017 1.537287 21.330017 c +1.850302 21.330017 2.160324 21.316515 2.466781 21.290033 c +2.832687 21.258417 3.103682 20.936161 3.072065 20.570255 c +3.040448 20.204350 2.718192 19.933353 2.352286 19.964972 c +2.083856 19.988165 1.812035 20.000017 1.537287 20.000017 c +1.262538 20.000017 0.990717 19.988165 0.722287 19.964972 c +0.356381 19.933353 0.034125 20.204350 0.002508 20.570255 c +-0.029109 20.936161 0.241886 21.258417 0.607792 21.290033 c +h +0.002508 0.759779 m +0.034125 1.125685 0.356381 1.396679 0.722287 1.365063 c +0.990717 1.341867 1.262538 1.330017 1.537287 1.330017 c +1.812035 1.330017 2.083856 1.341867 2.352286 1.365063 c +2.718192 1.396679 3.040448 1.125685 3.072065 0.759779 c +3.103682 0.393873 2.832687 0.071617 2.466781 0.039999 c +2.160324 0.013519 1.850302 0.000015 1.537287 0.000015 c +1.224271 0.000015 0.914249 0.013519 0.607792 0.039999 c +0.241886 0.071617 -0.029109 0.393873 0.002508 0.759779 c +h +f* +n +Q +q +1.000000 0.000000 -0.000000 1.000000 18.562012 5.086609 cm +0.000000 0.000000 0.000000 scn +2.718522 2.103071 m +2.929436 1.802403 2.856679 1.387682 2.556010 1.176766 c +2.050149 0.821909 1.511930 0.509789 0.946597 0.245714 c +0.613840 0.090281 0.218083 0.234030 0.062649 0.566786 c +-0.092785 0.899544 0.050963 1.295300 0.383720 1.450733 c +0.878228 1.681723 1.349272 1.954861 1.792216 2.265581 c +2.092885 2.476498 2.507605 2.403738 2.718522 2.103071 c +h +f* +n +Q +q +/E1 gs +1.000000 0.000000 -0.000000 1.000000 22.527100 8.415771 cm +0.000000 0.000000 0.000000 scn +1.819443 2.959568 m +2.152200 2.804134 2.295949 2.408377 2.140515 2.075620 c +1.876443 1.510287 1.564322 0.972069 1.209464 0.466206 c +0.998547 0.165539 0.583827 0.092780 0.283160 0.303696 c +-0.017509 0.514613 -0.090267 0.929333 0.120648 1.230002 c +0.431370 1.672945 0.704507 2.143989 0.935496 2.638497 c +1.090930 2.971253 1.486687 3.115002 1.819443 2.959568 c +h +f* +n +Q +q +/E2 gs +1.000000 0.000000 -0.000000 1.000000 24.297363 13.404541 cm +0.000000 0.000000 0.000000 scn +0.607792 3.130305 m +0.973697 3.161922 1.295953 2.890927 1.327572 2.525021 c +1.354051 2.218563 1.367555 1.908542 1.367555 1.595526 c +1.367555 1.282510 1.354051 0.972489 1.327572 0.666031 c +1.295953 0.300125 0.973697 0.029130 0.607792 0.060747 c +0.241886 0.092365 -0.029108 0.414621 0.002508 0.780526 c +0.025703 1.048956 0.037553 1.320777 0.037553 1.595526 c +0.037553 1.870275 0.025703 2.142096 0.002508 2.410526 c +-0.029108 2.776431 0.241886 3.098687 0.607792 3.130305 c +h +f* +n +Q +q +/E3 gs +1.000000 0.000000 -0.000000 1.000000 22.527100 18.379028 cm +0.000000 0.000000 0.000000 scn +0.283159 2.901568 m +0.583828 3.112484 0.998548 3.039725 1.209465 2.739057 c +1.564321 2.233195 1.876442 1.694978 2.140516 1.129645 c +2.295950 0.796888 2.152200 0.401131 1.819444 0.245697 c +1.486686 0.090263 1.090931 0.234012 0.935497 0.566768 c +0.704508 1.061275 0.431369 1.532320 0.120649 1.975264 c +-0.090267 2.275931 -0.017508 2.690652 0.283159 2.901568 c +h +f* +n +Q +q +/E4 gs +1.000000 0.000000 -0.000000 1.000000 18.562012 22.344177 cm +0.000000 0.000000 0.000000 scn +0.062649 2.002510 m +0.218083 2.335267 0.613840 2.479015 0.946597 2.323581 c +1.511930 2.059509 2.050148 1.747388 2.556011 1.392531 c +2.856678 1.181615 2.929437 0.766894 2.718521 0.466226 c +2.507604 0.165558 2.092884 0.092800 1.792215 0.303715 c +1.349271 0.614436 0.878228 0.887573 0.383720 1.118563 c +0.050964 1.273997 -0.092785 1.669754 0.062649 2.002510 c +h +f* +n +Q +q +1.000000 0.000000 -0.000000 1.000000 12.500000 9.737305 cm +0.000000 0.000000 0.000000 scn +2.718506 0.000000 m +2.324219 0.000000 2.075195 0.290527 2.075195 0.715942 c +2.075195 8.443481 l +1.099854 7.727539 l +0.913086 7.571899 0.809326 7.530396 0.601807 7.530396 c +0.269775 7.530396 0.000000 7.820923 0.000000 8.163330 c +0.000000 8.401978 0.114136 8.588745 0.352783 8.765137 c +1.774292 9.730103 l +2.147827 9.979126 2.355347 10.072510 2.645874 10.072510 c +3.112793 10.072510 3.372192 9.792358 3.372192 9.263184 c +3.372192 0.715942 l +3.372192 0.280151 3.123169 0.000000 2.718506 0.000000 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 4922 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 30.000000 30.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000216 00000 n +0000005194 00000 n +0000005217 00000 n +0000005390 00000 n +0000005464 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +5523 +%%EOF \ No newline at end of file diff --git a/Rosetta/Assets.xcassets/VoiceRecordingViewOnceEnabled.imageset/1filled.pdf b/Rosetta/Assets.xcassets/VoiceRecordingViewOnceEnabled.imageset/1filled.pdf new file mode 100644 index 0000000..19ddecd --- /dev/null +++ b/Rosetta/Assets.xcassets/VoiceRecordingViewOnceEnabled.imageset/1filled.pdf @@ -0,0 +1,85 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 4.169922 4.170044 cm +0.000000 0.000000 0.000000 scn +10.830000 21.659973 m +4.848756 21.659973 0.000000 16.811216 0.000000 10.829973 c +0.000000 4.848728 4.848756 -0.000027 10.830000 -0.000027 c +16.811245 -0.000027 21.660000 4.848728 21.660000 10.829973 c +21.660000 16.811216 16.811245 21.659973 10.830000 21.659973 c +h +10.775391 6.581909 m +10.775391 6.048706 11.130859 5.693237 11.643555 5.693237 c +12.156250 5.693237 12.511719 6.048706 12.511719 6.581909 c +12.511719 14.846558 l +12.511719 15.454956 12.135742 15.830933 11.513672 15.830933 c +11.144531 15.830933 10.871094 15.769409 10.433594 15.461792 c +8.416992 14.046753 l +8.123047 13.841675 8.013672 13.636597 8.013672 13.363159 c +8.013672 12.973511 8.280273 12.706909 8.649414 12.706909 c +8.840820 12.706909 8.977539 12.754761 9.148438 12.877808 c +10.741211 13.985229 l +10.775391 13.985229 l +10.775391 6.581909 l +h +f* +n +Q + +endstream +endobj + +3 0 obj + 941 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 30.000000 30.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001031 00000 n +0000001053 00000 n +0000001226 00000 n +0000001300 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1359 +%%EOF \ No newline at end of file diff --git a/Rosetta/Assets.xcassets/VoiceRecordingViewOnceEnabled.imageset/Contents.json b/Rosetta/Assets.xcassets/VoiceRecordingViewOnceEnabled.imageset/Contents.json new file mode 100644 index 0000000..bf64e7b --- /dev/null +++ b/Rosetta/Assets.xcassets/VoiceRecordingViewOnceEnabled.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "1filled.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index f385cd0..b713027 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -35,13 +35,18 @@ final class MessageRepository: ObservableObject { private var pendingCacheRefresh: Set = [] private var cacheRefreshTask: Task? + /// Guards against duplicate async initial loads for the same dialog. + private var initialLoadInFlight: Set = [] + /// Page size for initial message loading. Android: PAGE_SIZE = 30. static let pageSize = 50 /// Max messages per dialog in memory cache. /// Increased from 500 to 3000 for pagination support. /// With .equatable() cells, only ~15 visible cells render — RAM impact is ~600 KB. - static let maxCacheSize = 3000 + /// Telegram parity: keep ~200 messages in memory (sliding window). + /// Telegram uses ~90 (fillCount=44 × 2 directions). We use 200 for scroll buffer. + static let maxCacheSize = 200 private var db: DatabaseManager { DatabaseManager.shared } @@ -76,14 +81,54 @@ final class MessageRepository: ObservableObject { func messages(for dialogKey: String) -> [ChatMessage] { if let cached = messagesByDialog[dialogKey] { return cached } guard !currentAccount.isEmpty else { return [] } - let loaded = loadMessagesFromDB(dialogKey: dialogKey, limit: Self.pageSize) - messagesByDialog[dialogKey] = loaded - return loaded + // Async initial load: return [] immediately, decrypt on background thread. + // Combine pipeline in ChatDetailViewModel picks up the async result. + // Skeleton shows while isLoading = initial.isEmpty. + triggerAsyncInitialLoad(for: dialogKey) + return [] + } + + /// Trigger background decryption for initial dialog load. + /// DB read on MainActor (~5ms), AES decrypt on Task.detached (~150ms OFF main thread). + private func triggerAsyncInitialLoad(for dialogKey: String) { + guard !initialLoadInFlight.contains(dialogKey) else { return } + initialLoadInFlight.insert(dialogKey) + + let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey) + let pk = privateKey + let account = currentAccount + + Task { @MainActor [weak self] in + guard let self else { return } + let records: [MessageRecord] + do { + records = try db.read { db in + let descRequest = MessageRecord + .filter(MessageRecord.Columns.account == account && + MessageRecord.Columns.dialogKey == dbDialogKey) + .order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc) + .limit(Self.pageSize) + let recs = try descRequest.fetchAll(db) + return Array(recs.reversed()) + } + } catch { + self.initialLoadInFlight.remove(dialogKey) + return + } + + let messages = await Task.detached(priority: .userInitiated) { + Self.decryptRecordsBatch(records, privateKey: pk) + }.value + + self.messagesByDialog[dialogKey] = messages + self.initialLoadInFlight.remove(dialogKey) + } } /// Load older messages for pagination (scroll-to-load-more). /// Uses a composite cursor `(timestamp, messageId)` to avoid gaps when multiple /// messages share the same timestamp. + /// Sync version — used by tests. For UI pagination use `loadOlderMessagesAsync`. func loadOlderMessages( for dialogKey: String, beforeTimestamp: Int64, @@ -93,42 +138,13 @@ final class MessageRepository: ObservableObject { let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey) do { let records = try db.read { db in - if beforeMessageId.isEmpty { - return try MessageRecord - .filter( - MessageRecord.Columns.account == currentAccount && - MessageRecord.Columns.dialogKey == dbDialogKey && - MessageRecord.Columns.timestamp < beforeTimestamp - ) - .order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc) - .limit(limit) - .fetchAll(db) - } - - return try MessageRecord - .filter( - MessageRecord.Columns.account == currentAccount && - MessageRecord.Columns.dialogKey == dbDialogKey && - ( - MessageRecord.Columns.timestamp < beforeTimestamp || - ( - MessageRecord.Columns.timestamp == beforeTimestamp && - MessageRecord.Columns.messageId < beforeMessageId - ) - ) - ) - .order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc) - .limit(limit) - .fetchAll(db) + try Self.fetchOlderRecords( + db: db, account: currentAccount, dbDialogKey: dbDialogKey, + beforeTimestamp: beforeTimestamp, beforeMessageId: beforeMessageId, limit: limit + ) } let older = records.reversed().map { decryptRecord($0) } - // Prepend to cache - if var cached = messagesByDialog[dialogKey] { - let existingIds = Set(cached.map(\.id)) - let newMessages = older.filter { !existingIds.contains($0.id) } - cached = newMessages + cached - messagesByDialog[dialogKey] = cached - } + Self.prependToCache(&messagesByDialog, dialogKey: dialogKey, newMessages: older) return older } catch { print("[DB] loadOlderMessages error: \(error)") @@ -136,6 +152,262 @@ final class MessageRepository: ObservableObject { } } + /// Async version: DB read on MainActor (~5ms), decryption on background thread (~150ms). + /// Eliminates main thread freeze during scroll pagination. + func loadOlderMessagesAsync( + for dialogKey: String, + beforeTimestamp: Int64, + beforeMessageId: String, + limit: Int = 50 + ) async -> [ChatMessage] { + let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey) + let pk = privateKey + + // Step 1: DB read on MainActor (GRDB DatabasePool.read is fast, ~5ms for 50 rows) + let records: [MessageRecord] + do { + records = try db.read { db in + try Self.fetchOlderRecords( + db: db, account: currentAccount, dbDialogKey: dbDialogKey, + beforeTimestamp: beforeTimestamp, beforeMessageId: beforeMessageId, limit: limit + ) + } + } catch { + print("[DB] loadOlderMessagesAsync error: \(error)") + return [] + } + let reversed = Array(records.reversed()) + + // Step 2: Decrypt on background thread (AES × 50 = ~150ms, OFF main thread) + let older = await Task.detached(priority: .userInitiated) { + Self.decryptRecordsBatch(reversed, privateKey: pk) + }.value + + // Step 3: Cache update on MainActor + Self.prependToCache(&messagesByDialog, dialogKey: dialogKey, newMessages: older) + return older + } + + /// Reverse pagination: load newer messages when scrolling back toward bottom. + /// Sync version — used by tests. For UI pagination use `loadNewerMessagesAsync`. + func loadNewerMessages( + for dialogKey: String, + afterTimestamp: Int64, + afterMessageId: String, + limit: Int = pageSize + ) -> [ChatMessage] { + let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey) + do { + let records = try db.read { db in + try Self.fetchNewerRecords( + db: db, account: currentAccount, dbDialogKey: dbDialogKey, + afterTimestamp: afterTimestamp, afterMessageId: afterMessageId, limit: limit + ) + } + let newer = records.map { decryptRecord($0) } + Self.appendToCache(&messagesByDialog, dialogKey: dialogKey, newMessages: newer) + return newer + } catch { + print("[DB] loadNewerMessages error: \(error)") + return [] + } + } + + /// Async version: DB read on MainActor, decryption on background thread. + func loadNewerMessagesAsync( + for dialogKey: String, + afterTimestamp: Int64, + afterMessageId: String, + limit: Int = pageSize + ) async -> [ChatMessage] { + let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey) + let pk = privateKey + + let records: [MessageRecord] + do { + records = try db.read { db in + try Self.fetchNewerRecords( + db: db, account: currentAccount, dbDialogKey: dbDialogKey, + afterTimestamp: afterTimestamp, afterMessageId: afterMessageId, limit: limit + ) + } + } catch { + print("[DB] loadNewerMessagesAsync error: \(error)") + return [] + } + + let newer = await Task.detached(priority: .userInitiated) { + Self.decryptRecordsBatch(records, privateKey: pk) + }.value + + Self.appendToCache(&messagesByDialog, dialogKey: dialogKey, newMessages: newer) + return newer + } + + // MARK: - Background Decrypt (nonisolated) + + /// Thread-safe batch decryption for pagination. Runs on background thread. + /// Skips `persistReDecryptedText` (MainActor DB write) and GroupRepository access. + /// Re-decrypt persistence happens on next main-thread load if needed. + nonisolated private static func decryptRecordsBatch( + _ records: [MessageRecord], privateKey: String + ) -> [ChatMessage] { + records.map { record in + let plainText: String + + if !privateKey.isEmpty { + if let data = try? CryptoManager.shared.decryptWithPassword( + record.text, password: privateKey, requireCompression: true + ), let decrypted = String(data: data, encoding: .utf8) { + plainText = decrypted + } else if let data = try? CryptoManager.shared.decryptWithPassword( + record.text, password: privateKey + ), let decrypted = String(data: data, encoding: .utf8), + !isProbablyEncryptedPayloadNonisolated(decrypted) { + plainText = decrypted + } else { + let fallback = safePlainMessageFallbackNonisolated(record.text) + if fallback.isEmpty, !record.content.isEmpty, !record.chachaKey.isEmpty { + if let (text, _) = try? MessageCrypto.decryptIncomingFull( + ciphertext: record.content, + encryptedKey: record.chachaKey, + myPrivateKeyHex: privateKey + ) { + plainText = text + } else { + plainText = fallback + } + } else { + plainText = fallback + } + } + } else { + plainText = safePlainMessageFallbackNonisolated(record.text) + } + + return record.toChatMessage(overrideText: plainText) + } + } + + /// Nonisolated version of `safePlainMessageFallback` for background decryption. + nonisolated private static func safePlainMessageFallbackNonisolated(_ raw: String) -> String { + if raw.isEmpty { return "" } + if isProbablyEncryptedPayloadNonisolated(raw) { return "" } + return raw + } + + /// Nonisolated version of `isProbablyEncryptedPayload` for background decryption. + nonisolated private static func isProbablyEncryptedPayloadNonisolated(_ value: String) -> Bool { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("CHNK:") { return true } + let parts = trimmed.components(separatedBy: ":") + if parts.count == 2 { + let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/=")) + if parts.allSatisfy({ part in + part.count >= 16 && part.unicodeScalars.allSatisfy { base64Chars.contains($0) } + }) { return true } + } + if trimmed.count >= 40 { + let hexChars = CharacterSet(charactersIn: "0123456789abcdefABCDEF") + if trimmed.unicodeScalars.allSatisfy({ hexChars.contains($0) }) { return true } + } + return false + } + + // MARK: - Cache Helpers + + private static func prependToCache( + _ cache: inout [String: [ChatMessage]], dialogKey: String, newMessages: [ChatMessage] + ) { + if var cached = cache[dialogKey] { + let existingIds = Set(cached.map(\.id)) + let filtered = newMessages.filter { !existingIds.contains($0.id) } + cached = filtered + cached + if cached.count > maxCacheSize * 3 { + cached = Array(cached.prefix(maxCacheSize * 2)) + } + cache[dialogKey] = cached + } + } + + private static func appendToCache( + _ cache: inout [String: [ChatMessage]], dialogKey: String, newMessages: [ChatMessage] + ) { + if var cached = cache[dialogKey] { + let existingIds = Set(cached.map(\.id)) + let filtered = newMessages.filter { !existingIds.contains($0.id) } + cached = cached + filtered + if cached.count > maxCacheSize * 3 { + cached = Array(cached.suffix(maxCacheSize * 2)) + } + cache[dialogKey] = cached + } + } + + // MARK: - DB Query Helpers + + /// Extract SQL queries as static methods so they can be called from GRDB `read` closures. + /// GRDB `read { db in }` closure runs on the pool's serial queue — NOT MainActor. + /// These must be `nonisolated` to be callable from that non-isolated context. + nonisolated private static func fetchOlderRecords( + db: Database, account: String, dbDialogKey: String, + beforeTimestamp: Int64, beforeMessageId: String, limit: Int + ) throws -> [MessageRecord] { + if beforeMessageId.isEmpty { + return try MessageRecord + .filter( + MessageRecord.Columns.account == account && + MessageRecord.Columns.dialogKey == dbDialogKey && + MessageRecord.Columns.timestamp < beforeTimestamp + ) + .order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc) + .limit(limit) + .fetchAll(db) + } + return try MessageRecord + .filter( + MessageRecord.Columns.account == account && + MessageRecord.Columns.dialogKey == dbDialogKey && + ( + MessageRecord.Columns.timestamp < beforeTimestamp || + ( + MessageRecord.Columns.timestamp == beforeTimestamp && + MessageRecord.Columns.messageId < beforeMessageId + ) + ) + ) + .order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc) + .limit(limit) + .fetchAll(db) + } + + nonisolated private static func fetchNewerRecords( + db: Database, account: String, dbDialogKey: String, + afterTimestamp: Int64, afterMessageId: String, limit: Int + ) throws -> [MessageRecord] { + try MessageRecord + .filter( + MessageRecord.Columns.account == account && + MessageRecord.Columns.dialogKey == dbDialogKey && + ( + MessageRecord.Columns.timestamp > afterTimestamp || + ( + MessageRecord.Columns.timestamp == afterTimestamp && + MessageRecord.Columns.messageId > afterMessageId + ) + ) + ) + .order(MessageRecord.Columns.timestamp.asc, MessageRecord.Columns.messageId.asc) + .limit(limit) + .fetchAll(db) + } + + /// Reload latest messages for a dialog (used when jumping to bottom from detached state). + func reloadLatest(for dialogKey: String) { + let messages = loadMessagesFromDB(dialogKey: dialogKey, limit: Self.maxCacheSize) + messagesByDialog[dialogKey] = messages + } + /// Android parity: two-level dedup (in-memory LRU + DB check). func hasMessage(_ messageId: String) -> Bool { // Level 1: in-memory LRU cache (O(1)) @@ -442,10 +714,29 @@ final class MessageRepository: ObservableObject { } } - // Outgoing user-sent messages: immediate cache refresh (bypass 100ms debounce) - // so the bubble appears instantly. Sync/incoming still use debounced path. + // Outgoing user-sent messages: in-memory append (bypass 100ms debounce + full re-decrypt). + // Construct ChatMessage from already-decrypted text — 0ms vs 150ms refreshDialogCache. if fromMe && !fromSync { - refreshDialogCache(for: opponentKey) + let chatMessage = ChatMessage( + id: messageId, + fromPublicKey: packet.fromPublicKey, + toPublicKey: packet.toPublicKey, + text: decryptedText, + timestamp: timestamp, + deliveryStatus: .waiting, + isRead: false, + attachments: packet.attachments, + attachmentPassword: attachmentPassword + ) + if var cached = messagesByDialog[opponentKey] { + if !cached.contains(where: { $0.id == messageId }) { + cached.append(chatMessage) + messagesByDialog[opponentKey] = cached + } + } else { + // First message in this dialog — initialize cache. + messagesByDialog[opponentKey] = [chatMessage] + } NotificationCenter.default.post( name: .sentMessageInserted, object: nil, @@ -503,7 +794,17 @@ final class MessageRepository: ObservableObject { } if let opponentKey { - refreshCacheNow(for: opponentKey) + // Performance: patch cache in-memory instead of full reload+decrypt. + if var cached = messagesByDialog[opponentKey], + let idx = cached.firstIndex(where: { $0.id == messageId }) { + cached[idx].deliveryStatus = status + if let ts = newTimestamp { + cached[idx].timestamp = ts + } + messagesByDialog[opponentKey] = cached + } else { + refreshCacheNow(for: opponentKey) + } } } catch { print("[DB] updateDeliveryStatus error: \(error)") @@ -523,7 +824,17 @@ final class MessageRepository: ObservableObject { } catch { print("[DB] markIncomingAsRead error: \(error)") } - refreshCacheNow(for: opponentKey) + // Performance: patch cache in-memory instead of full reload+decrypt. + if var cached = messagesByDialog[opponentKey] { + var changed = false + for i in cached.indices where !cached[i].isFromMe(myPublicKey: myPublicKey) && !cached[i].isRead { + cached[i].isRead = true + changed = true + } + if changed { messagesByDialog[opponentKey] = cached } + } else { + refreshCacheNow(for: opponentKey) + } } func markOutgoingAsRead(opponentKey: String, myPublicKey: String) { @@ -542,7 +853,17 @@ final class MessageRepository: ObservableObject { } catch { print("[DB] markOutgoingAsRead error: \(error)") } - refreshCacheNow(for: opponentKey) + // Performance: patch cache in-memory instead of full reload+decrypt. + if var cached = messagesByDialog[opponentKey] { + var changed = false + for i in cached.indices where cached[i].isFromMe(myPublicKey: myPublicKey) && !cached[i].isRead { + cached[i].isRead = true + changed = true + } + if changed { messagesByDialog[opponentKey] = cached } + } else { + refreshCacheNow(for: opponentKey) + } } /// Updates attachment password for a specific message (used during retry with re-encryption). @@ -638,7 +959,11 @@ final class MessageRepository: ObservableObject { print("[DB] deleteMessage error: \(error)") } if let opponentKey = affectedOpponent { - refreshCacheNow(for: opponentKey) + // In-memory remove — no full re-decrypt needed. + if var cached = messagesByDialog[opponentKey] { + cached.removeAll { $0.id == id } + messagesByDialog[opponentKey] = cached + } } } @@ -728,7 +1053,12 @@ final class MessageRepository: ObservableObject { print("[DB] updateAttachments error: \(error)") } if let opponentKey = affectedOpponent { - refreshCacheNow(for: opponentKey) + // In-memory patch — no full re-decrypt needed. + if var cached = messagesByDialog[opponentKey], + let idx = cached.firstIndex(where: { $0.id == messageId }) { + cached[idx].attachments = attachments + messagesByDialog[opponentKey] = cached + } } } @@ -859,12 +1189,8 @@ final class MessageRepository: ObservableObject { /// Used when a message is inserted programmatically (e.g., call attachment) and must /// appear immediately regardless of dialog active state. func refreshDialogCache(for opponentKey: String) { - let messages = loadMessagesFromDB(dialogKey: opponentKey, limit: nil) - if messages.count > Self.maxCacheSize { - messagesByDialog[opponentKey] = Array(messages.suffix(Self.maxCacheSize)) - } else { - messagesByDialog[opponentKey] = messages - } + let messages = loadMessagesFromDB(dialogKey: opponentKey, limit: Self.maxCacheSize) + messagesByDialog[opponentKey] = messages } /// Immediately refresh all pending dialog caches. @@ -882,13 +1208,8 @@ final class MessageRepository: ObservableObject { /// Android parity: capped at MAX_CACHE_SIZE (500) to prevent OOM. private func refreshCacheNow(for opponentKey: String) { guard messagesByDialog[opponentKey] != nil || activeDialogs.contains(opponentKey) else { return } - let messages = loadMessagesFromDB(dialogKey: opponentKey, limit: nil) - // Cap at maxCacheSize — keep most recent messages - if messages.count > Self.maxCacheSize { - messagesByDialog[opponentKey] = Array(messages.suffix(Self.maxCacheSize)) - } else { - messagesByDialog[opponentKey] = messages - } + let messages = loadMessagesFromDB(dialogKey: opponentKey, limit: Self.maxCacheSize) + messagesByDialog[opponentKey] = messages } private func loadMessagesFromDB(dialogKey: String, limit: Int?) -> [ChatMessage] { @@ -1044,32 +1365,44 @@ final class MessageRepository: ObservableObject { guard !groupKey.isEmpty, !privateKeyHex.isEmpty else { return } let dialogKey = DatabaseManager.dialogKey(account: account, opponentKey: groupDialogKey) - let records: [MessageRecord] - do { - records = try db.read { db in - try MessageRecord - .filter( - MessageRecord.Columns.account == account && - MessageRecord.Columns.dialogKey == dialogKey && - MessageRecord.Columns.content != "" - ) - .fetchAll(db) - } - } catch { return } + // Batch processing with LIMIT to prevent OOM on large groups. + let batchSize = 100 + var offset = 0 + var totalUpdated = 0 - var updated = 0 - for record in records { - // Try decrypt with group key. - guard let data = try? CryptoManager.shared.decryptWithPassword( - record.content, password: groupKey - ), let text = String(data: data, encoding: .utf8), !text.isEmpty else { - continue + while true { + let records: [MessageRecord] + do { + records = try db.read { db in + try MessageRecord + .filter( + MessageRecord.Columns.account == account && + MessageRecord.Columns.dialogKey == dialogKey && + MessageRecord.Columns.content != "" + ) + .order(MessageRecord.Columns.timestamp.asc) + .limit(batchSize, offset: offset) + .fetchAll(db) + } + } catch { break } + + guard !records.isEmpty else { break } + + for record in records { + guard let data = try? CryptoManager.shared.decryptWithPassword( + record.content, password: groupKey + ), let text = String(data: data, encoding: .utf8), !text.isEmpty else { + continue + } + persistReDecryptedText(text, forMessageId: record.messageId, privateKey: privateKeyHex) + totalUpdated += 1 } - persistReDecryptedText(text, forMessageId: record.messageId, privateKey: privateKeyHex) - updated += 1 + + offset += batchSize + if records.count < batchSize { break } } - if updated > 0 { + if totalUpdated > 0 { refreshDialogCache(for: groupDialogKey) } } diff --git a/Rosetta/Core/Layout/LayoutEngine.swift b/Rosetta/Core/Layout/LayoutEngine.swift new file mode 100644 index 0000000..4df163a --- /dev/null +++ b/Rosetta/Core/Layout/LayoutEngine.swift @@ -0,0 +1,58 @@ +import Foundation + +// MARK: - LayoutEngine + +/// Background layout calculation via Swift Actor. +/// Replaces synchronous main-thread layout with async computation. +/// Actor serializes requests — race conditions impossible. +actor LayoutEngine { + + static let shared = LayoutEngine() + + private var generation: UInt64 = 0 + + // MARK: - Request / Result + + struct LayoutRequest: Sendable { + let messages: [ChatMessage] + let maxBubbleWidth: CGFloat + let currentPublicKey: String + let opponentPublicKey: String + let opponentTitle: String + let isGroupChat: Bool + let groupAdminKey: String + let isDarkMode: Bool + let dirtyIds: Set? + let existingLayouts: [String: MessageCellLayout]? + let existingTextLayouts: [String: CoreTextTextLayout]? + } + + struct LayoutResult: Sendable { + let layouts: [String: MessageCellLayout] + let textLayouts: [String: CoreTextTextLayout] + let generation: UInt64 + } + + // MARK: - Calculate + + func calculate(_ request: LayoutRequest) -> LayoutResult { + generation += 1 + let gen = generation + + let (layouts, textLayouts) = MessageCellLayout.batchCalculate( + messages: request.messages, + maxBubbleWidth: request.maxBubbleWidth, + currentPublicKey: request.currentPublicKey, + opponentPublicKey: request.opponentPublicKey, + opponentTitle: request.opponentTitle, + isGroupChat: request.isGroupChat, + groupAdminKey: request.groupAdminKey, + isDarkMode: request.isDarkMode, + dirtyIds: request.dirtyIds, + existingLayouts: request.existingLayouts, + existingTextLayouts: request.existingTextLayouts + ) + + return LayoutResult(layouts: layouts, textLayouts: textLayouts, generation: gen) + } +} diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index 83679fd..d837c7f 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -948,10 +948,13 @@ extension MessageCellLayout { opponentTitle: String, isGroupChat: Bool = false, groupAdminKey: String = "", - isDarkMode: Bool = true + isDarkMode: Bool = true, + dirtyIds: Set? = nil, + existingLayouts: [String: MessageCellLayout]? = nil, + existingTextLayouts: [String: CoreTextTextLayout]? = nil ) -> (layouts: [String: MessageCellLayout], textLayouts: [String: CoreTextTextLayout]) { - var result: [String: MessageCellLayout] = [:] - var textResult: [String: CoreTextTextLayout] = [:] + var result: [String: MessageCellLayout] = existingLayouts ?? [:] + var textResult: [String: CoreTextTextLayout] = existingTextLayouts ?? [:] let timestampFormatter = DateFormatter() timestampFormatter.dateFormat = "HH:mm" timestampFormatter.locale = .autoupdatingCurrent @@ -967,6 +970,10 @@ extension MessageCellLayout { diffYearFormatter.locale = .autoupdatingCurrent for (index, message) in messages.enumerated() { + // Incremental: skip messages not in dirty set (reuse existing cache) + if let dirtyIds, !dirtyIds.contains(message.id), result[message.id] != nil { + continue + } let isOutgoing = message.fromPublicKey == currentPublicKey let messageDate = Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1000) let timestampText = timestampFormatter.string(from: messageDate) diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index c0bc664..90954b8 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -1363,11 +1363,13 @@ final class SessionManager { return } - DialogRepository.shared.markOutgoingAsRead(opponentKey: opponentKey) + // Order matters: update DB first, THEN read from DB for dialog. + // Without this order, DialogRepository reads stale is_read=0. MessageRepository.shared.markOutgoingAsRead( opponentKey: opponentKey, myPublicKey: ownKey ) + DialogRepository.shared.markOutgoingAsRead(opponentKey: opponentKey) // Race fix: if sync is in progress, messages may not be in DB yet. // markOutgoingAsRead() may have updated 0 rows. Store for re-application // at BATCH_END after waitForInboundQueueToDrain() ensures all messages @@ -1860,6 +1862,7 @@ final class SessionManager { let myUsername = AccountManager.shared.currentAccount?.username?.lowercased() ?? "" let mentioned = (!myUsername.isEmpty && lowered.contains("@\(myUsername)")) || lowered.contains("@\(myKey.prefix(8))") + || lowered.contains("@all") if mentioned { DialogRepository.shared.setMention(opponentKey: opponentKey, hasMention: true) } diff --git a/Rosetta/Core/Utils/UITestLaunchConfiguration.swift b/Rosetta/Core/Utils/UITestLaunchConfiguration.swift new file mode 100644 index 0000000..9468461 --- /dev/null +++ b/Rosetta/Core/Utils/UITestLaunchConfiguration.swift @@ -0,0 +1,15 @@ +import Foundation + +enum UITestLaunchConfiguration { + static var isVoiceRecordingFixtureEnabled: Bool { + ProcessInfo.processInfo.arguments.contains("-ui-test-voice-recording-fixture") + } + + static var voiceRecordingFixtureMode: String { + let env = ProcessInfo.processInfo.environment + if let mode = env["UI_TEST_VOICE_RECORDING_MODE"], !mode.isEmpty { + return mode + } + return "idle" + } +} diff --git a/Rosetta/DesignSystem/Components/WaveformView.swift b/Rosetta/DesignSystem/Components/WaveformView.swift index a8c7c8c..d0d4b41 100644 --- a/Rosetta/DesignSystem/Components/WaveformView.swift +++ b/Rosetta/DesignSystem/Components/WaveformView.swift @@ -30,6 +30,10 @@ final class WaveformView: UIView { didSet { setNeedsDisplay() } } + /// Cached resampled data — recomputed only when samples or view width changes. + private var cachedResampled: [Float] = [] + private var cachedWidth: CGFloat = 0 + // MARK: - Init override init(frame: CGRect) { @@ -54,6 +58,8 @@ final class WaveformView: UIView { func setSamples(_ newSamples: [Float]) { samples = newSamples + cachedResampled = [] // Invalidate cache + cachedWidth = 0 setNeedsDisplay() } @@ -67,7 +73,12 @@ final class WaveformView: UIView { let numSamples = Int(floor(size.width / (sampleWidth + distance))) guard numSamples > 0 else { return } - let resampled = resample(samples, toCount: numSamples) + // Use cached resampled data when samples and width haven't changed + if cachedWidth != size.width || cachedResampled.count != numSamples { + cachedResampled = resample(samples, toCount: numSamples) + cachedWidth = size.width + } + let resampled = cachedResampled // Telegram: diff = sampleWidth * 1.5 = 3.0 (subtracted from bar height) let diff: CGFloat = sampleWidth * 1.5 diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 32a2156..c07f04a 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -48,6 +48,8 @@ struct ChatDetailView: View { @State private var forwardingMessage: ChatMessage? @State private var pendingGroupInvite: String? @State private var pendingGroupInviteTitle: String? + @State private var mentionChatRoute: ChatRoute? + @State private var avatarProfileRoute: ChatRoute? @State private var messageToDelete: ChatMessage? // Image viewer is presented via ImageViewerPresenter (UIKit overFullScreen), // not via SwiftUI fullScreenCover, to avoid bottom-sheet slide-up animation. @@ -55,8 +57,9 @@ struct ChatDetailView: View { @State private var scrollToMessageId: String? /// ID of message currently highlighted after scroll-to-reply navigation. @State private var highlightedMessageId: String? - /// Triggers NativeMessageList to scroll to bottom (button tap). - @State private var scrollToBottomRequested = false + /// Triggers NativeMessageList to scroll to bottom (button tap). Increment-only counter + /// avoids @Binding write-back cycle (true→false causes double updateUIViewController). + @State private var scrollToBottomTrigger: UInt = 0 // Multi-select @State private var isMultiSelectMode = false @@ -195,7 +198,9 @@ struct ChatDetailView: View { @ViewBuilder private var content: some View { + #if DEBUG let _ = PerformanceLogger.shared.track("chatDetail.bodyEval") + #endif // iOS 26+ and iOS < 26 use the same UIKit ComposerView bridge. // #available branches stay explicit to keep platform separation intact. Group { @@ -269,6 +274,38 @@ struct ChatDetailView: View { selectedMessageIds.insert(msgId) } } + cellActions.onMentionTap = { [self] username in + // @all — no action (desktop parity) + guard username.lowercased() != "all" else { return } + // Tap own username → Saved Messages + let myUsername = AccountManager.shared.currentAccount?.username?.lowercased() ?? "" + if !myUsername.isEmpty && username.lowercased() == myUsername { + if let saved = DialogRepository.shared.sortedDialogs.first(where: { $0.isSavedMessages }) { + mentionChatRoute = ChatRoute(dialog: saved) + } else if let myKey = AccountManager.shared.currentAccount?.publicKey { + mentionChatRoute = ChatRoute(publicKey: myKey, title: "Saved Messages", username: "", verified: 0) + } + return + } + // Find dialog by username → push directly (no flash to chat list) + if let dialog = DialogRepository.shared.sortedDialogs.first(where: { + $0.opponentUsername.lowercased() == username.lowercased() + }) { + mentionChatRoute = ChatRoute(dialog: dialog) + } + } + cellActions.onAvatarTap = { [self] senderKey in + // Build route from sender's public key → open profile + if let dialog = DialogRepository.shared.sortedDialogs.first(where: { + $0.opponentKey == senderKey + }) { + avatarProfileRoute = ChatRoute(dialog: dialog) + } else { + // User not in dialogs — build minimal route from key + let title = String(senderKey.prefix(8)) + avatarProfileRoute = ChatRoute(publicKey: senderKey, title: title, username: "", verified: 0) + } + } cellActions.onGroupInviteOpen = { dialogKey in let title = DialogRepository.shared.dialogs[dialogKey]?.opponentTitle ?? "Group" let route = ChatRoute(groupDialogKey: dialogKey, title: title) @@ -356,6 +393,12 @@ struct ChatDetailView: View { .navigationDestination(isPresented: $showGroupInfo) { GroupInfoView(groupDialogKey: route.publicKey) } + .navigationDestination(item: $mentionChatRoute) { chatRoute in + ChatDetailView(route: chatRoute) + } + .navigationDestination(item: $avatarProfileRoute) { profileRoute in + OpponentProfileView(route: profileRoute) + } .sheet(isPresented: $showForwardPicker) { ForwardChatPickerView { targetRoutes in showForwardPicker = false @@ -687,28 +730,11 @@ private extension ChatDetailView { } ToolbarItem(placement: .navigationBarTrailing) { - HStack(spacing: 8) { - if canStartCall { - Button { startVoiceCall() } label: { - Image(systemName: "phone.fill") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(RosettaColors.Adaptive.text) - .frame(width: 36, height: 36) - .contentShape(Circle()) - .background { - glass(shape: .circle, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) - } - } - .buttonStyle(.plain) - .accessibilityLabel("Start Call") - } - - Button { openProfile() } label: { - ChatDetailToolbarAvatar(route: route, size: 35) - .frame(width: 36, height: 36) - .contentShape(Circle()) - .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) } - } + Button { openProfile() } label: { + ChatDetailToolbarAvatar(route: route, size: 35) + .frame(width: 36, height: 36) + .contentShape(Circle()) + .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) } } .buttonStyle(.plain) } @@ -735,28 +761,11 @@ private extension ChatDetailView { } ToolbarItem(placement: .navigationBarTrailing) { - HStack(spacing: 8) { - if canStartCall { - Button { startVoiceCall() } label: { - Image(systemName: "phone.fill") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(RosettaColors.Adaptive.text) - .frame(width: 44, height: 44) - .contentShape(Circle()) - .background { - glass(shape: .circle, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) - } - } - .buttonStyle(.plain) - .accessibilityLabel("Start Call") - } - - Button { openProfile() } label: { - ChatDetailToolbarAvatar(route: route, size: 38) - .frame(width: 44, height: 44) - .contentShape(Circle()) - .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) } - } + Button { openProfile() } label: { + ChatDetailToolbarAvatar(route: route, size: 38) + .frame(width: 44, height: 44) + .contentShape(Circle()) + .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) } } .buttonStyle(.plain) } @@ -1012,10 +1021,8 @@ private extension ChatDetailView { @ViewBuilder func messagesList(maxBubbleWidth: CGFloat) -> some View { - if viewModel.isLoading && messages.isEmpty { - // Android parity: skeleton placeholder while loading from DB - ChatDetailSkeletonView(maxBubbleWidth: maxBubbleWidth) - } else if route.isSystemAccount && messages.isEmpty { + // Skeleton loading is now handled inside NativeMessageListController (UIKit) + if route.isSystemAccount && messages.isEmpty { emptyStateView } else { messagesScrollView(maxBubbleWidth: maxBubbleWidth) @@ -1100,7 +1107,7 @@ private extension ChatDetailView { ) : nil, scrollToMessageId: scrollToMessageId, shouldScrollToBottom: shouldScrollOnNextMessage, - scrollToBottomRequested: $scrollToBottomRequested, + scrollToBottomTrigger: scrollToBottomTrigger, onAtBottomChange: { atBottom in isAtBottom = atBottom SessionManager.shared.resetIdleTimer() @@ -1112,6 +1119,13 @@ private extension ChatDetailView { onPaginate: { Task { await viewModel.loadMore() } }, + onBottomPaginate: { + Task { await viewModel.loadNewer() } + }, + onJumpToBottom: { + viewModel.jumpToBottom() + }, + hasNewerMessagesFlag: viewModel.hasNewerMessages, onTapBackground: { isInputFocused = false }, diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift index 1275a6f..48f3c50 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift @@ -19,6 +19,12 @@ final class ChatDetailViewModel: ObservableObject { @Published private(set) var hasMoreMessages: Bool = true /// Pagination: guard against concurrent loads. @Published private(set) var isLoadingMore: Bool = false + /// Reverse pagination: true while newer messages exist below current window. + @Published private(set) var hasNewerMessages: Bool = false + /// True when user scrolled up past the initial window (not showing latest). + @Published private(set) var isDetachedFromBottom: Bool = false + /// Guard against concurrent downward loads. + private var isLoadingNewer: Bool = false private var cancellables = Set() @@ -50,7 +56,13 @@ final class ChatDetailViewModel: ObservableObject { } .debounce(for: .milliseconds(50), scheduler: DispatchQueue.main) .removeDuplicates { (lhs: [ChatMessage], rhs: [ChatMessage]) -> Bool in + // O(1) fast path for common cases (insert/delete). guard lhs.count == rhs.count else { return false } + guard lhs.first?.id == rhs.first?.id, + lhs.last?.id == rhs.last?.id else { return false } + // O(n) scan for status/read changes — needed to detect mid-array + // delivery ACK and read receipt updates. ~0.3ms for 3000 messages, + // runs at most once per 50ms debounce — negligible vs layout cost. for i in lhs.indices { if lhs[i].id != rhs[i].id || lhs[i].deliveryStatus != rhs[i].deliveryStatus || @@ -109,12 +121,13 @@ final class ChatDetailViewModel: ObservableObject { } /// Pagination: load older messages from SQLite when user scrolls to top. + /// Async: DB read + AES decryption run on background thread — zero main thread freeze. func loadMore() async { guard !isLoadingMore, hasMoreMessages else { return } guard let earliest = messages.first else { return } isLoadingMore = true - let older = MessageRepository.shared.loadOlderMessages( + let older = await MessageRepository.shared.loadOlderMessagesAsync( for: dialogKey, beforeTimestamp: earliest.timestamp, beforeMessageId: earliest.id, @@ -124,10 +137,40 @@ final class ChatDetailViewModel: ObservableObject { if older.count < MessageRepository.pageSize { hasMoreMessages = false } - // messages will update via Combine pipeline (repo already prepends to cache). isLoadingMore = false } + /// Reverse pagination: load newer messages when scrolling back toward bottom. + /// Async: DB read + AES decryption run on background thread. + func loadNewer() async { + guard !isLoadingNewer, hasNewerMessages else { return } + guard let latest = messages.last else { return } + isLoadingNewer = true + + let newer = await MessageRepository.shared.loadNewerMessagesAsync( + for: dialogKey, + afterTimestamp: latest.timestamp, + afterMessageId: latest.id, + limit: MessageRepository.pageSize + ) + + if newer.count < MessageRepository.pageSize { + hasNewerMessages = false + isDetachedFromBottom = false + } + isLoadingNewer = false + } + + /// Jump to latest messages when detached from bottom (scroll-to-bottom button). + func jumpToBottom() { + if isDetachedFromBottom { + MessageRepository.shared.reloadLatest(for: dialogKey) + hasNewerMessages = false + isDetachedFromBottom = false + hasMoreMessages = true + } + } + /// Ensures a target message is present in current dialog cache before scroll-to-message. /// Returns true when the message is available to the UI list. func ensureMessageLoaded(messageId: String) async -> Bool { diff --git a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift index 7486727..9a7d315 100644 --- a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift @@ -29,6 +29,33 @@ protocol ComposerViewDelegate: AnyObject { /// Frame-based layout (Telegram pattern), no Auto Layout inside. final class ComposerView: UIView, UITextViewDelegate { + struct VoiceRecordingRuntimeGuards { + var isCallIdle: () -> Bool + var requestMicrophonePermission: () async -> Bool + var hasSufficientDiskSpace: (Int64) -> Bool + + static let live = VoiceRecordingRuntimeGuards( + isCallIdle: { CallManager.shared.uiState.phase == .idle }, + requestMicrophonePermission: { await AudioRecorder.requestMicrophonePermission() }, + hasSufficientDiskSpace: { minBytes in + Self.defaultHasSufficientDiskSpace(minBytes: minBytes) + } + ) + + private static func defaultHasSufficientDiskSpace(minBytes: Int64) -> Bool { + let home = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + let keys: Set = [ + .volumeAvailableCapacityForImportantUsageKey, + .volumeAvailableCapacityKey + ] + guard let values = try? home.resourceValues(forKeys: keys) else { return true } + let important = values.volumeAvailableCapacityForImportantUsage ?? Int64.max + let generic = Int64(values.volumeAvailableCapacity ?? Int.max) + let available = min(important, generic) + return available >= minBytes + } + } + weak var delegate: ComposerViewDelegate? // MARK: - Public State @@ -42,6 +69,23 @@ final class ComposerView: UIView, UITextViewDelegate { /// from auto-restoring first responder on transition cancellation. var isFocusBlocked = false + // MARK: - Mention Autocomplete + + var isGroupChat: Bool = false + var groupDialogKey: String = "" + /// Autocomplete table — added as subview of inputContainer (unified glass). + private var mentionTableView: UITableView? + private var mentionCandidates: [MentionCandidate] = [] + private var mentionCandidatesCache: [MentionCandidate]? + /// Height of the autocomplete section inside inputContainer. + private var mentionAutocompleteHeight: CGFloat = 0 + /// Resolved members from PacketSearch (not dependent on DialogRepository having a dialog). + private var resolvedMembers: [String: (username: String, title: String)] = [:] + private var mentionSearchHandlerId: UUID? + + private let mentionRowHeight: CGFloat = 42 + private let mentionMaxRows: CGFloat = 5 + // MARK: - Subviews // Attach button (glass circle, 42×42) @@ -77,7 +121,7 @@ final class ComposerView: UIView, UITextViewDelegate { private var attachIconLayer: CAShapeLayer? private var emojiIconLayer: CAShapeLayer? private var sendIconLayer: CAShapeLayer? - private var micIconLayer: CAShapeLayer? + private var micIconView: UIImageView? // MARK: - Layout Constants @@ -109,6 +153,7 @@ final class ComposerView: UIView, UITextViewDelegate { private var recordingPreviewPanel: RecordingPreviewPanel? private var recordingStartTask: Task? private var recordingSendAccessibilityButton: UIButton? + private var isPreviewReplacingInputRow = false private(set) var isRecording = false private(set) var isRecordingLocked = false private(set) var recordingFlowState: VoiceRecordingFlowState = .idle @@ -119,8 +164,19 @@ final class ComposerView: UIView, UITextViewDelegate { private(set) var lastRecordedWaveform: [Float] = [] private(set) var lastVoiceSendTransitionSource: VoiceSendTransitionSource? - private let minVoiceDuration: TimeInterval = 0.5 - private let minFreeDiskBytes: Int64 = 8 * 1024 * 1024 + private let minVoiceDuration = VoiceRecordingParityConstants.minVoiceDuration + private let minFreeDiskBytes = VoiceRecordingParityConstants.minFreeDiskBytes + var runtimeGuards: VoiceRecordingRuntimeGuards = .live + + private enum VoiceSessionCleanupMode { + case preserveRecordedDraft + case discardRecording + } + + private enum VoiceSessionDismissStyle { + case standard + case cancel + } // MARK: - Init @@ -207,6 +263,7 @@ final class ComposerView: UIView, UITextViewDelegate { textView.placeholderLabel.text = "Message" textView.placeholderLabel.font = .systemFont(ofSize: 17, weight: .regular) textView.placeholderLabel.textColor = .placeholderText + textView.accessibilityIdentifier = "voice.composer.textView" textView.trackingView.onHeightChange = { [weak self] height in guard let self else { return } @@ -251,19 +308,18 @@ final class ComposerView: UIView, UITextViewDelegate { micGlass.isCircle = true micGlass.isUserInteractionEnabled = false micButton.addSubview(micGlass) - let micIcon = makeIconLayer( - pathData: TelegramIconPath.microphone, - viewBox: CGSize(width: 18, height: 24), - targetSize: CGSize(width: 18, height: 24), - color: .label - ) - micButton.layer.addSublayer(micIcon) - micIconLayer = micIcon + let micIconView = UIImageView(image: VoiceRecordingAssets.image(.iconMicrophone, templated: true)) + micIconView.contentMode = .center + micIconView.tintColor = .label + micIconView.isUserInteractionEnabled = false + micButton.addSubview(micIconView) + self.micIconView = micIconView micButton.tag = 4 micButton.recordingDelegate = self micButton.isAccessibilityElement = true micButton.accessibilityLabel = "Voice message" micButton.accessibilityHint = "Hold to record voice message. Slide left to cancel or up to lock." + micButton.accessibilityIdentifier = "voice.mic.button" addSubview(micButton) updateThemeColors() @@ -340,6 +396,65 @@ final class ComposerView: UIView, UITextViewDelegate { return lastVoiceSendTransitionSource } +#if DEBUG + func debugSetRecordingStopAccessibilityAreaEnabled(_ isEnabled: Bool) { + updateRecordingSendAccessibilityArea(isEnabled: isEnabled) + } + + func debugShowPreviewReplacingInputRow(fileURL: URL, duration: TimeInterval, waveform: [Float]) { + lastRecordedURL = fileURL + lastRecordedDuration = duration + lastRecordedWaveform = waveform + + setPreviewRowReplacement(true) + recordingPreviewPanel?.removeFromSuperview() + let preview = RecordingPreviewPanel( + frame: inputContainer.bounds, + fileURL: fileURL, + duration: duration, + waveform: waveform + ) + preview.delegate = self + inputContainer.addSubview(preview) + recordingPreviewPanel = preview + isRecording = false + isRecordingLocked = false + setRecordingFlowState(.draftPreview) + } + + var debugPreviewPanelFrame: CGRect? { + guard let recordingPreviewPanel else { return nil } + return recordingPreviewPanel.convert(recordingPreviewPanel.bounds, to: self) + } + + var debugIsPreviewReplacingInputRow: Bool { + isPreviewReplacingInputRow + } + + func debugForceVoiceSessionState(flow: VoiceRecordingFlowState, mic: VoiceRecordingState) { + setRecordingFlowState(flow) + isRecording = flow != .idle + isRecordingLocked = flow == .recordingLocked || flow == .waitingForPreview || flow == .draftPreview + micButton.debugSetRecordingState(mic) + } + + func debugFinalizeVoiceSession(skipAudioCleanup: Bool = true) { + resetVoiceSessionState(skipAudioCleanup: skipAudioCleanup) + recordingOverlay?.dismiss() + recordingOverlay = nil + recordingLockView?.removeFromSuperview() + recordingLockView = nil + recordingPanel?.removeFromSuperview() + recordingPanel = nil + recordingPreviewPanel?.removeFromSuperview() + recordingPreviewPanel = nil + } + + var debugMicRecordingState: VoiceRecordingState { + micButton.recordingState + } +#endif + private func setRecordingFlowState(_ state: VoiceRecordingFlowState) { recordingFlowState = state } @@ -357,8 +472,8 @@ final class ComposerView: UIView, UITextViewDelegate { // Text row height = textViewHeight (clamped) let textRowH = textViewHeight - // Input container inner height = padding + reply + text row + padding - let inputInnerH = innerPadding + replyH + textRowH + innerPadding + // Input container inner height = mention autocomplete + padding + reply + text row + padding + let inputInnerH = mentionAutocompleteHeight + innerPadding + replyH + textRowH + innerPadding let inputContainerH = max(minInputContainerHeight, inputInnerH) // Main bar height @@ -377,15 +492,23 @@ final class ComposerView: UIView, UITextViewDelegate { centerIconLayer(in: attachButton, iconSize: CGSize(width: 21, height: 24)) // Mic button - let showMic = !isSendVisible + let showMic = !isSendVisible && !isPreviewReplacingInputRow let micX = w - horizontalPadding - buttonSize let micY = topPadding + mainBarH - buttonSize micButton.frame = CGRect(x: micX, y: micY, width: buttonSize, height: buttonSize) micGlass.frame = micButton.bounds - centerIconLayer(in: micButton, iconSize: CGSize(width: 18, height: 24)) + if let micIconView { + let iconSize = micIconView.image?.size ?? CGSize(width: 30, height: 30) + micIconView.frame = CGRect( + x: floor((micButton.bounds.width - iconSize.width) / 2.0), + y: floor((micButton.bounds.height - iconSize.height) / 2.0), + width: iconSize.width, + height: iconSize.height + ) + } // Input container - let inputX = attachX + buttonSize + innerSpacing + let inputX = isPreviewReplacingInputRow ? horizontalPadding : (attachX + buttonSize + innerSpacing) let micWidth: CGFloat = showMic ? (buttonSize + innerSpacing) : 0 let inputW = w - inputX - horizontalPadding - micWidth let inputY = topPadding + mainBarH - inputContainerH @@ -398,15 +521,21 @@ final class ComposerView: UIView, UITextViewDelegate { inputGlass.fixedCornerRadius = cornerRadius inputGlass.applyCornerRadius() - // Reply bar inside input container + // Mention autocomplete table (inside inputContainer, at the top) + if let tv = mentionTableView { + tv.frame = CGRect(x: 0, y: 0, width: inputW, height: mentionAutocompleteHeight) + tv.isHidden = mentionAutocompleteHeight < 1 + } + + // Reply bar inside input container (shifted down by autocomplete height) let replyX: CGFloat = 6 let replyW = inputW - replyX - 4 - replyBar.frame = CGRect(x: replyX, y: innerPadding, width: replyW, height: replyH) + replyBar.frame = CGRect(x: replyX, y: mentionAutocompleteHeight + innerPadding, width: replyW, height: replyH) layoutReplyBar(width: replyW, height: replyH) - // Text view inside input container + // Text view inside input container (shifted down by autocomplete height) let textX: CGFloat = innerPadding + 6 - let textY = innerPadding + replyH + let textY = mentionAutocompleteHeight + innerPadding + replyH let sendExtraW: CGFloat = isSendVisible ? sendButtonWidth : 0 let emojiW: CGFloat = 20 let emojiTrailing: CGFloat = 8 + sendExtraW @@ -430,6 +559,9 @@ final class ComposerView: UIView, UITextViewDelegate { if recordingSendAccessibilityButton != nil { updateRecordingSendAccessibilityArea(isEnabled: true) } + if let recordingPreviewPanel { + recordingPreviewPanel.frame = inputContainer.bounds + } // Report height if abs(totalH - currentHeight) > 0.5 { @@ -493,7 +625,7 @@ final class ComposerView: UIView, UITextViewDelegate { attachIconLayer?.fillColor = UIColor.label.cgColor emojiIconLayer?.fillColor = UIColor.secondaryLabel.cgColor sendIconLayer?.fillColor = UIColor.white.cgColor - micIconLayer?.fillColor = UIColor.label.cgColor + micIconView?.tintColor = .label replyTitleLabel.textColor = .label replyPreviewLabel.textColor = .label @@ -541,7 +673,7 @@ final class ComposerView: UIView, UITextViewDelegate { private func reportHeightIfChanged() { let replyH: CGFloat = isReplyVisible ? 46 : 0 - let inputInnerH = innerPadding + replyH + textViewHeight + innerPadding + let inputInnerH = mentionAutocompleteHeight + innerPadding + replyH + textViewHeight + innerPadding let inputContainerH = max(minInputContainerHeight, inputInnerH) let mainBarH = max(buttonSize, inputContainerH) let totalH = topPadding + mainBarH + bottomPadding @@ -610,6 +742,7 @@ final class ComposerView: UIView, UITextViewDelegate { delegate?.composerTextDidChange(self, text: textView.text ?? "") recalculateTextHeight() updateSendMicVisibility(animated: true) + updateMentionAutocomplete() } // MARK: - Actions @@ -629,6 +762,277 @@ final class ComposerView: UIView, UITextViewDelegate { } } + // MARK: - Mention Autocomplete Logic + + private func updateMentionAutocomplete() { + guard isGroupChat else { + hideMentionAutocomplete() + return + } + + guard let query = extractMentionQuery() else { + hideMentionAutocomplete() + return + } + + let candidates = loadAndFilterCandidates(query: query) + if candidates.isEmpty { + hideMentionAutocomplete() + return + } + + showMentionAutocomplete(candidates: candidates) + } + + /// Extracts the @query at the cursor. Returns nil if no active mention trigger. + private func extractMentionQuery() -> String? { + let text = textView.text ?? "" + guard !text.isEmpty else { return nil } + let cursorLocation = textView.selectedRange.location + guard cursorLocation > 0, cursorLocation <= (text as NSString).length else { return nil } + + let nsText = text as NSString + // Scan backward from cursor to find "@" + var atIndex: Int? + for i in stride(from: cursorLocation - 1, through: 0, by: -1) { + let char = nsText.character(at: i) + if char == Character("@").asciiValue! { + // "@" must be at start or preceded by whitespace/newline + if i == 0 { + atIndex = i + } else { + let prevChar = nsText.character(at: i - 1) + if CharacterSet.whitespacesAndNewlines.contains(Unicode.Scalar(prevChar)!) { + atIndex = i + } + } + break + } + // Stop if we hit whitespace before finding "@" + if CharacterSet.whitespacesAndNewlines.contains(Unicode.Scalar(char)!) { + break + } + } + + guard let idx = atIndex else { return nil } + let queryStart = idx + 1 // after "@" + let queryLength = cursorLocation - queryStart + if queryLength < 0 { return nil } + if queryLength == 0 { return "" } // just "@" typed, show all + return nsText.substring(with: NSRange(location: queryStart, length: queryLength)) + } + + /// Loads group members (cached) and filters by query. + private func loadAndFilterCandidates(query: String) -> [MentionCandidate] { + if mentionCandidatesCache == nil { + mentionCandidatesCache = resolveMentionCandidates() + } + guard let all = mentionCandidatesCache else { return [] } + + let lowQuery = query.lowercased() + var result: [MentionCandidate] = [] + + // @all special entry + if lowQuery.isEmpty || "all".hasPrefix(lowQuery) { + result.append(.allMembers()) + } + + for candidate in all { + if lowQuery.isEmpty + || candidate.username.lowercased().hasPrefix(lowQuery) + || candidate.title.lowercased().hasPrefix(lowQuery) { + result.append(candidate) + } + } + return result + } + + /// Resolves group members from GroupRepository + DialogRepository + resolvedMembers cache. + /// Uses direct PacketSearch with own handler — doesn't depend on DialogRepository + /// having a dialog entry (updateUserInfo silently drops unknown users). + private func resolveMentionCandidates() -> [MentionCandidate] { + let account = SessionManager.shared.currentPublicKey + guard let cached = GroupRepository.shared.cachedMembers( + account: account, groupDialogKey: groupDialogKey + ) else { return [] } + + let myKey = account + var candidates: [MentionCandidate] = [] + var unknownKeys: [String] = [] + + for memberKey in cached.memberKeys { + if memberKey == myKey { continue } + + // 1. Check DialogRepository (users with prior direct chat) + let dialog = DialogRepository.shared.dialogs[memberKey] + let dialogUsername = dialog?.opponentUsername ?? "" + let dialogTitle = dialog?.opponentTitle ?? "" + + if !dialogUsername.isEmpty { + candidates.append(MentionCandidate( + username: dialogUsername, + title: dialogTitle.isEmpty ? dialogUsername : dialogTitle, + publicKey: memberKey, isAll: false + )) + continue + } + + // 2. Check resolvedMembers (from PacketSearch responses) + if let resolved = resolvedMembers[memberKey], !resolved.username.isEmpty { + candidates.append(MentionCandidate( + username: resolved.username, + title: resolved.title.isEmpty ? resolved.username : resolved.title, + publicKey: memberKey, isAll: false + )) + continue + } + + unknownKeys.append(memberKey) + } + + if !unknownKeys.isEmpty { + fetchUnknownMembers(unknownKeys) + } + + return candidates.sorted { $0.username.lowercased() < $1.username.lowercased() } + } + + /// Sends PacketSearch for unknown members using a dedicated search channel. + /// Registers own handler to capture responses directly — bypasses DialogRepository + /// (which silently drops users without existing dialog entries). + private func fetchUnknownMembers(_ keys: [String]) { + guard let privKeyHash = SessionManager.shared.privateKeyHash else { return } + + // Register one-shot handler per batch + let searchId = UUID() + var remaining = keys.count + + let handlerId = ProtocolManager.shared.addSearchResultHandler(channel: .ui(searchId)) { [weak self] packet in + Task { @MainActor [weak self] in + guard let self else { return } + for user in packet.users where !user.username.isEmpty { + self.resolvedMembers[user.publicKey] = (username: user.username, title: user.title) + } + remaining -= 1 + if remaining <= 0 { + // All responses received — rebuild cache + if let hid = self.mentionSearchHandlerId { + ProtocolManager.shared.removeSearchResultHandler(hid) + self.mentionSearchHandlerId = nil + } + self.mentionCandidatesCache = nil + self.updateMentionAutocomplete() + } + } + } + mentionSearchHandlerId = handlerId + + for key in keys { + var packet = PacketSearch() + packet.search = key + packet.privateKey = privKeyHash + ProtocolManager.shared.sendSearchPacket(packet, channel: .ui(searchId)) + } + } + + /// Ensures the mention table view is created and added inside inputContainer. + private func ensureMentionTableView() { + guard mentionTableView == nil else { return } + let tv = UITableView(frame: .zero, style: .plain) + tv.dataSource = self + tv.delegate = self + tv.backgroundColor = .clear + tv.separatorStyle = .none + tv.showsVerticalScrollIndicator = false + tv.register(MentionCell.self, forCellReuseIdentifier: MentionCell.reuseID) + tv.rowHeight = mentionRowHeight + // Insert below text views but above glass + inputContainer.insertSubview(tv, aboveSubview: inputGlass) + mentionTableView = tv + } + + private func showMentionAutocomplete(candidates: [MentionCandidate]) { + ensureMentionTableView() + mentionCandidates = candidates + + let newHeight = min(CGFloat(candidates.count) * mentionRowHeight, mentionMaxRows * mentionRowHeight) + let changed = abs(mentionAutocompleteHeight - newHeight) > 0.5 + + mentionAutocompleteHeight = newHeight + mentionTableView?.reloadData() + + if changed { + // Trigger re-layout — inputContainer will grow upward to include autocomplete rows + UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0) { + self.recalculateTextHeight() + self.layoutSubviews() + self.delegate?.composerHeightDidChange(self, height: self.currentHeight) + } + } + } + + private func hideMentionAutocomplete() { + guard mentionAutocompleteHeight > 0 else { return } + mentionAutocompleteHeight = 0 + mentionCandidates = [] + + UIView.animate(withDuration: 0.2) { + self.recalculateTextHeight() + self.layoutSubviews() + self.delegate?.composerHeightDidChange(self, height: self.currentHeight) + } + } + + /// Inserts the selected mention into the text view. + func insertMention(username: String) { + guard let query = extractMentionQuery() else { return } + let text = textView.text ?? "" + let nsText = text as NSString + let cursorLocation = textView.selectedRange.location + + let atPosition = cursorLocation - query.count - 1 + guard atPosition >= 0 else { return } + + let replaceRange = NSRange(location: atPosition, length: query.count + 1) + let replacement = "@\(username) " + let newText = nsText.replacingCharacters(in: replaceRange, with: replacement) + + textView.text = newText + textView.selectedRange = NSRange(location: atPosition + replacement.count, length: 0) + textViewDidChange(textView) + } + + /// Preloads group member info via PacketSearch for members not in DialogRepository. + /// Called eagerly when entering a group chat so member usernames are available by + /// the time the user types "@". + func preloadMentionMembers() { + guard isGroupChat, !groupDialogKey.isEmpty else { return } + let account = SessionManager.shared.currentPublicKey + guard let cached = GroupRepository.shared.cachedMembers( + account: account, groupDialogKey: groupDialogKey + ) else { return } + + let unknownKeys = cached.memberKeys.filter { key in + key != account + && (DialogRepository.shared.dialogs[key]?.opponentUsername ?? "").isEmpty + && resolvedMembers[key] == nil + } + if !unknownKeys.isEmpty { + fetchUnknownMembers(unknownKeys) + } + } + + /// Clears cached candidates and removes search handler. + func clearMentionCache() { + mentionCandidatesCache = nil + resolvedMembers.removeAll() + if let hid = mentionSearchHandlerId { + ProtocolManager.shared.removeSearchResultHandler(hid) + mentionSearchHandlerId = nil + } + hideMentionAutocomplete() + } + @objc private func replyCancelTapped() { delegate?.composerDidCancelReply(self) } @@ -652,16 +1056,16 @@ extension ComposerView: RecordingMicButtonDelegate { recordingStartTask?.cancel() recordingStartTask = Task { @MainActor [weak self] in guard let self else { return } - guard CallManager.shared.uiState.phase == .idle else { + guard self.runtimeGuards.isCallIdle() else { self.failRecordingStart(for: button) return } - guard self.hasSufficientDiskSpaceForRecording() else { + guard self.runtimeGuards.hasSufficientDiskSpace(self.minFreeDiskBytes) else { self.failRecordingStart(for: button) return } - let granted = await AudioRecorder.requestMicrophonePermission() + let granted = await self.runtimeGuards.requestMicrophonePermission() guard !Task.isCancelled else { return } guard granted else { self.failRecordingStart(for: button) @@ -705,6 +1109,7 @@ extension ComposerView: RecordingMicButtonDelegate { recordingPanel?.showCancelButton() recordingLockView?.showStopButton { [weak self] in self?.showRecordingPreview() + self?.micButton.resetState() } recordingOverlay?.transitionToLocked(onTapStop: { [weak self] in self?.showRecordingPreview() @@ -718,7 +1123,7 @@ extension ComposerView: RecordingMicButtonDelegate { func micButtonDragUpdate(_ button: RecordingMicButton, distanceX: CGFloat, distanceY: CGFloat) { recordingOverlay?.applyDragTransform(distanceX: distanceX, distanceY: distanceY) recordingPanel?.updateCancelTranslation(distanceX) - let lockness = min(1, max(0, abs(distanceY) / 105)) + let lockness = VoiceRecordingParityMath.lockness(distanceY: distanceY) recordingLockView?.updateLockness(lockness) } @@ -736,7 +1141,7 @@ extension ComposerView: RecordingMicButtonDelegate { lastRecordedDuration = snapshot.duration lastRecordedWaveform = snapshot.waveform - if snapshot.duration < minVoiceDuration { + if VoiceRecordingParityMath.shouldDiscard(duration: snapshot.duration) { dismissOverlayAndRestore() return } @@ -750,22 +1155,20 @@ extension ComposerView: RecordingMicButtonDelegate { } updateRecordingSendAccessibilityArea(isEnabled: false) - guard let url = lastRecordedURL else { return } - let panelX = horizontalPadding - let panelW = micButton.frame.minX - innerSpacing - horizontalPadding + guard let url = lastRecordedURL else { + dismissOverlayAndRestore(skipAudioCleanup: true) + return + } + setPreviewRowReplacement(true) + micButton.resetState() let preview = RecordingPreviewPanel( - frame: CGRect( - x: panelX, - y: inputContainer.frame.origin.y, - width: panelW, - height: inputContainer.frame.height - ), + frame: inputContainer.bounds, fileURL: url, duration: lastRecordedDuration, waveform: lastRecordedWaveform ) preview.delegate = self - addSubview(preview) + inputContainer.addSubview(preview) preview.animateIn() recordingPreviewPanel = preview isRecording = false @@ -782,8 +1185,8 @@ extension ComposerView: RecordingMicButtonDelegate { audioRecorder.onLevelUpdate = nil audioRecorder.stopRecording() - guard lastRecordedDuration >= minVoiceDuration else { - dismissOverlayAndRestore(skipAudioCleanup: true) + guard !VoiceRecordingParityMath.shouldDiscard(duration: lastRecordedDuration) else { + dismissOverlayAndRestore() return } @@ -839,8 +1242,9 @@ extension ComposerView: RecordingMicButtonDelegate { UIView.animate(withDuration: 0.15) { self.inputContainer.alpha = 0 self.attachButton.alpha = 0 + self.micButton.alpha = 0 self.micGlass.alpha = 0 - self.micIconLayer?.opacity = 0 + self.micIconView?.alpha = 0 } } @@ -848,12 +1252,70 @@ extension ComposerView: RecordingMicButtonDelegate { UIView.animate(withDuration: 0.15) { self.inputContainer.alpha = 1 self.attachButton.alpha = 1 + self.micButton.alpha = self.isSendVisible ? 0 : 1 + self.micButton.transform = self.isSendVisible ? CGAffineTransform(scaleX: 0.42, y: 0.78) : .identity + self.micButton.isUserInteractionEnabled = true self.micGlass.alpha = 1 - self.micIconLayer?.opacity = 1 + self.micIconView?.alpha = 1 } updateSendMicVisibility(animated: false) } + private func setPreviewRowReplacement(_ enabled: Bool) { + guard isPreviewReplacingInputRow != enabled else { return } + isPreviewReplacingInputRow = enabled + + textView.isHidden = enabled + textView.isUserInteractionEnabled = !enabled + emojiButton.isHidden = enabled + emojiButton.isUserInteractionEnabled = !enabled + sendButton.isHidden = enabled + sendButton.isUserInteractionEnabled = !enabled + replyBar.isHidden = enabled ? true : !isReplyVisible + inputGlass.alpha = enabled ? 0 : 1 + micButton.isUserInteractionEnabled = !enabled + + if enabled { + inputContainer.alpha = 1 + attachButton.alpha = 0 + micButton.alpha = 0 + micGlass.alpha = 0 + micIconView?.alpha = 0 + bringSubviewToFront(inputContainer) + } else { + replyBar.alpha = isReplyVisible ? 1 : 0 + bringSubviewToFront(micButton) + } + + setNeedsLayout() + layoutIfNeeded() + } + + private func resetVoiceSessionState(skipAudioCleanup: Bool) { + let cleanupMode: VoiceSessionCleanupMode = skipAudioCleanup ? .preserveRecordedDraft : .discardRecording + resetVoiceSessionState(cleanup: cleanupMode) + } + + private func resetVoiceSessionState(cleanup: VoiceSessionCleanupMode) { + isRecording = false + isRecordingLocked = false + setRecordingFlowState(.idle) + recordingStartTask?.cancel() + recordingStartTask = nil + audioRecorder.onLevelUpdate = nil + switch cleanup { + case .discardRecording: + audioRecorder.cancelRecording() + case .preserveRecordedDraft: + // Keep already captured file URL for send transition, but always + // return recorder FSM to idle to allow immediate next recording. + audioRecorder.reset() + } + updateRecordingSendAccessibilityArea(isEnabled: false) + setPreviewRowReplacement(false) + micButton.resetState() + } + private func failRecordingStart(for button: RecordingMicButton) { let feedback = UINotificationFeedbackGenerator() feedback.notificationOccurred(.warning) @@ -861,19 +1323,6 @@ extension ComposerView: RecordingMicButtonDelegate { button.resetState() } - private func hasSufficientDiskSpaceForRecording() -> Bool { - let home = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) - let keys: Set = [ - .volumeAvailableCapacityForImportantUsageKey, - .volumeAvailableCapacityKey - ] - guard let values = try? home.resourceValues(forKeys: keys) else { return true } - let important = values.volumeAvailableCapacityForImportantUsage ?? Int64.max - let generic = Int64(values.volumeAvailableCapacity ?? Int.max) - let available = min(important, generic) - return available >= minFreeDiskBytes - } - private func updateRecordingSendAccessibilityArea(isEnabled: Bool) { if !isEnabled { recordingSendAccessibilityButton?.removeFromSuperview() @@ -890,69 +1339,77 @@ extension ComposerView: RecordingMicButtonDelegate { button.isAccessibilityElement = true button.accessibilityLabel = "Stop recording" button.accessibilityHint = "Stops recording and opens voice preview." + button.accessibilityIdentifier = "voice.recording.stopArea" button.addTarget(self, action: #selector(accessibilityStopRecordingTapped), for: .touchUpInside) recordingSendAccessibilityButton = button window.addSubview(button) } let micCenter = convert(micButton.center, to: window) - button.frame = CGRect(x: micCenter.x - 60, y: micCenter.y - 60, width: 120, height: 120) + let hitSize = VoiceRecordingParityConstants.sendAccessibilityHitSize + button.frame = CGRect( + x: micCenter.x - hitSize / 2.0, + y: micCenter.y - hitSize / 2.0, + width: hitSize, + height: hitSize + ) } @objc private func accessibilityStopRecordingTapped() { showRecordingPreview() + micButton.resetState() + } + + private func finalizeVoiceSession(cleanup: VoiceSessionCleanupMode, dismissStyle: VoiceSessionDismissStyle) { + resetVoiceSessionState(cleanup: cleanup) + + switch dismissStyle { + case .standard: + recordingOverlay?.dismiss() + recordingPanel?.animateOut { [weak self] in + self?.recordingPanel = nil + } + restoreComposerChrome() + case .cancel: + recordingOverlay?.dismissCancel() + recordingPanel?.animateOutCancel { [weak self] in + self?.recordingPanel = nil + self?.restoreComposerChrome() + } + if recordingPanel == nil { + restoreComposerChrome() + } + } + recordingOverlay = nil + + recordingLockView?.dismiss() + recordingLockView = nil + + recordingPreviewPanel?.animateOut { [weak self] in + self?.recordingPreviewPanel = nil + } + } + + private func clearLastRecordedDraftFile() { + if let url = lastRecordedURL { + try? FileManager.default.removeItem(at: url) + } + lastRecordedURL = nil + lastRecordedDuration = 0 + lastRecordedWaveform = [] + lastVoiceSendTransitionSource = nil } private func cancelRecordingWithDismissAnimation() { - isRecording = false - isRecordingLocked = false - setRecordingFlowState(.idle) - audioRecorder.onLevelUpdate = nil - audioRecorder.cancelRecording() - - recordingOverlay?.dismissCancel() - recordingOverlay = nil - - recordingLockView?.dismiss() - recordingLockView = nil - - recordingPanel?.animateOutCancel { [weak self] in - self?.recordingPanel = nil - } - - recordingPreviewPanel?.animateOut { [weak self] in - self?.recordingPreviewPanel = nil - } - - updateRecordingSendAccessibilityArea(isEnabled: false) - restoreComposerChrome() + clearLastRecordedDraftFile() + finalizeVoiceSession(cleanup: .discardRecording, dismissStyle: .cancel) } private func dismissOverlayAndRestore(skipAudioCleanup: Bool = false) { - isRecording = false - isRecordingLocked = false - setRecordingFlowState(.idle) - recordingStartTask?.cancel() - recordingStartTask = nil - audioRecorder.onLevelUpdate = nil - if !skipAudioCleanup { - audioRecorder.cancelRecording() + let cleanupMode: VoiceSessionCleanupMode = skipAudioCleanup ? .preserveRecordedDraft : .discardRecording + if cleanupMode == .discardRecording { + clearLastRecordedDraftFile() } - - recordingOverlay?.dismiss() - recordingOverlay = nil - - recordingLockView?.dismiss() - recordingLockView = nil - - recordingPanel?.animateOut { [weak self] in - self?.recordingPanel = nil - } - - recordingPreviewPanel?.animateOut { [weak self] in - self?.recordingPreviewPanel = nil - } - updateRecordingSendAccessibilityArea(isEnabled: false) - restoreComposerChrome() + finalizeVoiceSession(cleanup: cleanupMode, dismissStyle: .standard) } private func captureVoiceSendTransition(from sourceView: UIView?) -> VoiceSendTransitionSource? { @@ -971,6 +1428,8 @@ extension ComposerView: RecordingMicButtonDelegate { } private func resumeRecordingFromPreview() { + setPreviewRowReplacement(false) + micButton.resetState() guard audioRecorder.resumeRecording() else { dismissOverlayAndRestore() return @@ -987,9 +1446,7 @@ extension ComposerView: RecordingMicButtonDelegate { } private func clampTrimRange(_ trimRange: ClosedRange, duration: TimeInterval) -> ClosedRange { - let lower = max(0, min(trimRange.lowerBound, duration)) - let upper = max(lower, min(trimRange.upperBound, duration)) - return lower...upper + VoiceRecordingParityMath.clampTrimRange(trimRange, duration: duration) } private func trimWaveform( @@ -997,11 +1454,14 @@ extension ComposerView: RecordingMicButtonDelegate { totalDuration: TimeInterval, trimRange: ClosedRange ) -> [Float] { - guard !waveform.isEmpty, totalDuration > 0 else { return waveform } - let startIndex = max(0, Int(floor((trimRange.lowerBound / totalDuration) * Double(waveform.count)))) - let endIndex = min(waveform.count, Int(ceil((trimRange.upperBound / totalDuration) * Double(waveform.count)))) - guard startIndex < endIndex else { return waveform } - return Array(waveform[startIndex..) async -> URL? { @@ -1074,9 +1534,9 @@ extension ComposerView: RecordingMicButtonDelegate { try? FileManager.default.removeItem(at: url) } - guard finalDuration >= minVoiceDuration else { + guard !VoiceRecordingParityMath.shouldDiscard(duration: finalDuration) else { try? FileManager.default.removeItem(at: finalURL) - dismissOverlayAndRestore(skipAudioCleanup: true) + dismissOverlayAndRestore() return } @@ -1109,13 +1569,7 @@ extension ComposerView: RecordingPreviewPanelDelegate { func previewPanelDidTapDelete(_ panel: RecordingPreviewPanel) { audioRecorder.cancelRecording() - if let url = lastRecordedURL { - try? FileManager.default.removeItem(at: url) - } - lastRecordedURL = nil - lastRecordedDuration = 0 - lastRecordedWaveform = [] - + clearLastRecordedDraftFile() dismissOverlayAndRestore(skipAudioCleanup: true) delegate?.composerDidCancelRecording(self) } @@ -1124,3 +1578,26 @@ extension ComposerView: RecordingPreviewPanelDelegate { resumeRecordingFromPreview() } } + +// MARK: - Mention Table DataSource & Delegate + +extension ComposerView: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + mentionCandidates.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: MentionCell.reuseID, for: indexPath) as! MentionCell + let candidate = mentionCandidates[indexPath.row] + let isLast = indexPath.row == mentionCandidates.count - 1 + cell.configure(candidate: candidate, showSeparator: !isLast) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let candidate = mentionCandidates[indexPath.row] + insertMention(username: candidate.username) + hideMentionAutocomplete() + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/CoreTextLabel.swift b/Rosetta/Features/Chats/ChatDetail/CoreTextLabel.swift index cb9b72f..7abda50 100644 --- a/Rosetta/Features/Chats/ChatDetail/CoreTextLabel.swift +++ b/Rosetta/Features/Chats/ChatDetail/CoreTextLabel.swift @@ -16,7 +16,7 @@ import CoreText /// Two-phase pattern (matches Telegram asyncLayout): /// 1. `CoreTextTextLayout.calculate()` — runs on ANY thread (background-safe) /// 2. `CoreTextLabel.draw()` — runs on main thread, renders pre-calculated lines -final class CoreTextTextLayout { +final class CoreTextTextLayout: @unchecked Sendable { // MARK: - Line @@ -39,6 +39,22 @@ final class CoreTextTextLayout { var rects: [CGRect] } + // MARK: - Mention Detection + + /// A detected @username with bounding rects for hit testing. + struct MentionInfo { + let username: String // without the "@" prefix + let range: NSRange // range of "@username" in the original string + var rects: [CGRect] // populated during layout + } + + /// Cached mention regex (compilation is expensive). + private static let mentionDetector: NSRegularExpression? = { + // Match @username at start of string or after whitespace. + // Username: 1-32 alphanumeric or underscore characters. + try? NSRegularExpression(pattern: "(?:^|(?<=\\s))@([A-Za-z0-9_]{1,32})", options: []) + }() + /// TLD whitelist — desktop parity (desktop/app/constants.ts lines 38-63). static let allowedTLDs: Set = [ "com", "ru", "ua", "org", "net", "edu", "gov", "io", "tech", "info", @@ -63,6 +79,7 @@ final class CoreTextTextLayout { let lastLineHasBlockQuote: Bool let textColor: UIColor let links: [LinkInfo] + let mentions: [MentionInfo] private init( lines: [Line], @@ -71,7 +88,8 @@ final class CoreTextTextLayout { lastLineHasRTL: Bool, lastLineHasBlockQuote: Bool, textColor: UIColor, - links: [LinkInfo] = [] + links: [LinkInfo] = [], + mentions: [MentionInfo] = [] ) { self.lines = lines self.size = size @@ -80,6 +98,7 @@ final class CoreTextTextLayout { self.lastLineHasBlockQuote = lastLineHasBlockQuote self.textColor = textColor self.links = links + self.mentions = mentions } /// Returns the URL at the given point, or nil if no link at that position. @@ -94,6 +113,18 @@ final class CoreTextTextLayout { return nil } + /// Returns the username at the given point, or nil if no mention at that position. + func mentionAt(point: CGPoint) -> String? { + for mention in mentions { + for rect in mention.rects { + if rect.insetBy(dx: -4, dy: -4).contains(point) { + return mention.username + } + } + } + return nil + } + // MARK: - Telegram Line Spacing /// Telegram default: 12% of font line height. @@ -163,6 +194,29 @@ final class CoreTextTextLayout { } } + // ── Mention detection (@username — desktop/Android parity) ── + var detectedMentions: [(username: String, range: NSRange)] = [] + if let mentionRegex = mentionDetector { + let fullRange = NSRange(location: 0, length: stringLength) + mentionRegex.enumerateMatches(in: text, options: [], range: fullRange) { result, _, _ in + guard let result else { return } + let matchRange = result.range // full "@username" + let usernameRange = result.range(at: 1) // "username" capture group + guard usernameRange.location != NSNotFound else { return } + // Skip if overlaps with a detected link + let overlapsLink = detectedLinks.contains { link in + NSIntersectionRange(link.range, matchRange).length > 0 + } + guard !overlapsLink else { return } + let username = (text as NSString).substring(with: usernameRange) + // Blue color + underline (Telegram parity) + attrString.addAttribute(.foregroundColor, value: linkColor as UIColor, range: matchRange) + attrString.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: matchRange) + attrString.addAttribute(.underlineColor, value: linkColor as UIColor, range: matchRange) + detectedMentions.append((username: username, range: matchRange)) + } + } + // ── Typesetter (Telegram: InteractiveTextComponent line 1481) ── let typesetter = CTTypesetterCreateWithAttributedString(attrString as CFAttributedString) @@ -269,6 +323,28 @@ final class CoreTextTextLayout { } } + // ── Compute mention bounding rects (same algorithm as links) ── + var mentionInfos: [MentionInfo] = [] + for detected in detectedMentions { + var rects: [CGRect] = [] + for line in resultLines { + let overlap = NSIntersectionRange(line.stringRange, detected.range) + guard overlap.length > 0 else { continue } + var xStart = CGFloat(CTLineGetOffsetForStringIndex( + line.ctLine, overlap.location, nil + )) + var xEnd = CGFloat(CTLineGetOffsetForStringIndex( + line.ctLine, overlap.location + overlap.length, nil + )) + if xEnd < xStart { swap(&xStart, &xEnd) } + let lineH = line.ascent + line.descent + rects.append(CGRect(x: xStart, y: line.origin.y, width: xEnd - xStart, height: lineH)) + } + if !rects.isEmpty { + mentionInfos.append(MentionInfo(username: detected.username, range: detected.range, rects: rects)) + } + } + return CoreTextTextLayout( lines: resultLines, size: CGSize(width: ceil(maxLineWidth), height: ceil(currentY)), @@ -276,7 +352,8 @@ final class CoreTextTextLayout { lastLineHasRTL: lastLineHasRTL, lastLineHasBlockQuote: lastLineHasBlockQuote, textColor: textColor, - links: linkInfos + links: linkInfos, + mentions: mentionInfos ) } diff --git a/Rosetta/Features/Chats/ChatDetail/MentionAutocompleteView.swift b/Rosetta/Features/Chats/ChatDetail/MentionAutocompleteView.swift new file mode 100644 index 0000000..010db97 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/MentionAutocompleteView.swift @@ -0,0 +1,280 @@ +import SwiftUI +import UIKit + +// MARK: - Data Model + +struct MentionCandidate { + let username: String + let title: String + let publicKey: String + let isAll: Bool + + static func allMembers() -> MentionCandidate { + MentionCandidate(username: "all", title: "All Members", publicKey: "", isAll: true) + } +} + +// MARK: - Delegate + +@MainActor +protocol MentionAutocompleteDelegate: AnyObject { + func mentionAutocomplete(_ view: MentionAutocompleteView, didSelect candidate: MentionCandidate) +} + +// MARK: - MentionAutocompleteView + +/// UIKit mention autocomplete panel — Telegram-exact dimensions. +/// Appears above the composer when user types "@" in a group chat. +/// +/// Reference: MentionChatInputContextPanelNode.swift + MentionChatInputPanelItem.swift +/// Row: 42pt | Avatar: 30x30 at x=12 | Text at x=55 | Font: 14pt medium/regular +/// Background: UIVisualEffectView (.ultraThinMaterial), corner radius 20pt +final class MentionAutocompleteView: UIView, UITableViewDataSource, UITableViewDelegate { + + weak var mentionDelegate: MentionAutocompleteDelegate? + /// Corner radius matching inputContainer — passed from ComposerView. + var panelCornerRadius: CGFloat = 21 + + private let glassBackground = TelegramGlassUIView() + private let tableView = UITableView(frame: .zero, style: .plain) + private var candidates: [MentionCandidate] = [] + + // Telegram-exact constants + private let rowHeight: CGFloat = 42 + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + private func setupViews() { + // Glass background (TelegramGlassUIView — same as input container) + glassBackground.isUserInteractionEnabled = false + addSubview(glassBackground) + + tableView.dataSource = self + tableView.delegate = self + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.showsVerticalScrollIndicator = false + tableView.register(MentionCell.self, forCellReuseIdentifier: MentionCell.reuseID) + tableView.rowHeight = rowHeight + addSubview(tableView) + + clipsToBounds = true + } + + override func layoutSubviews() { + super.layoutSubviews() + glassBackground.frame = bounds + tableView.frame = bounds + + // Top-only rounding via maskedCorners (not layer.mask — masks break glass backdrop). + // Matches inputContainer radius so panel + input look like ONE unified glass element. + layer.cornerRadius = panelCornerRadius + layer.cornerCurve = .continuous + layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + + glassBackground.fixedCornerRadius = panelCornerRadius + glassBackground.applyCornerRadius() + glassBackground.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + } + + // MARK: - Public API + + func update(candidates: [MentionCandidate]) { + self.candidates = candidates + tableView.reloadData() + } + + var preferredHeight: CGFloat { + min(CGFloat(candidates.count) * rowHeight, 5 * rowHeight) + } + + // MARK: - UITableViewDataSource + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + candidates.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: MentionCell.reuseID, for: indexPath) as! MentionCell + let candidate = candidates[indexPath.row] + let isLast = indexPath.row == candidates.count - 1 + cell.configure(candidate: candidate, showSeparator: !isLast) + return cell + } + + // MARK: - UITableViewDelegate + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let candidate = candidates[indexPath.row] + mentionDelegate?.mentionAutocomplete(self, didSelect: candidate) + } +} + +// MARK: - MentionCell + +final class MentionCell: UITableViewCell { + + static let reuseID = "MentionCell" + + // Telegram-exact constants + private let avatarSize: CGFloat = 30 + private let avatarLeftInset: CGFloat = 12 + private let textLeftOffset: CGFloat = 55 + private let rightPadding: CGFloat = 10 + + private let avatarCircle = UIView() + private let avatarImageView = UIImageView() + private let avatarInitialLabel = UILabel() + private let allLabel = UILabel() + private let nameLabel = UILabel() + private let usernameLabel = UILabel() + private let separatorLine = UIView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .clear + contentView.backgroundColor = .clear + selectionStyle = .none + + avatarCircle.clipsToBounds = true + contentView.addSubview(avatarCircle) + + avatarImageView.contentMode = .scaleAspectFill + avatarImageView.clipsToBounds = true + avatarCircle.addSubview(avatarImageView) + + avatarInitialLabel.textAlignment = .center + avatarInitialLabel.font = UIFont.systemFont(ofSize: 12, weight: .bold).rounded() + avatarCircle.addSubview(avatarInitialLabel) + + allLabel.text = "@" + allLabel.textAlignment = .center + allLabel.textColor = .white + allLabel.font = .systemFont(ofSize: 16, weight: .bold) + allLabel.isHidden = true + avatarCircle.addSubview(allLabel) + + nameLabel.font = .systemFont(ofSize: 14, weight: .medium) + nameLabel.textColor = .white + contentView.addSubview(nameLabel) + + usernameLabel.font = .systemFont(ofSize: 14, weight: .regular) + usernameLabel.textColor = UIColor(white: 1, alpha: 0.55) + contentView.addSubview(usernameLabel) + + separatorLine.backgroundColor = UIColor.white.withAlphaComponent(0.08) + contentView.addSubview(separatorLine) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + func configure(candidate: MentionCandidate, showSeparator: Bool) { + let h: CGFloat = 42 + let w = UIScreen.main.bounds.width + + // Avatar + let avatarY = (h - avatarSize) / 2 + avatarCircle.frame = CGRect(x: avatarLeftInset, y: avatarY, width: avatarSize, height: avatarSize) + avatarCircle.layer.cornerRadius = avatarSize / 2 + avatarImageView.frame = avatarCircle.bounds + avatarInitialLabel.frame = avatarCircle.bounds + allLabel.frame = avatarCircle.bounds + + if candidate.isAll { + avatarCircle.backgroundColor = UIColor(RosettaColors.primaryBlue) + avatarImageView.isHidden = true + avatarInitialLabel.isHidden = true + allLabel.isHidden = false + } else { + allLabel.isHidden = true + let avatar = AvatarRepository.shared.loadAvatar(publicKey: candidate.publicKey) + if let avatar { + avatarImageView.image = avatar + avatarImageView.isHidden = false + avatarInitialLabel.isHidden = true + avatarCircle.backgroundColor = .clear + } else { + avatarImageView.image = nil + avatarImageView.isHidden = true + avatarInitialLabel.isHidden = false + let initial = String(candidate.title.prefix(1)).uppercased() + avatarInitialLabel.text = initial + let colorIndex = RosettaColors.avatarColorIndex(for: candidate.title, publicKey: candidate.publicKey) + // Mantine "light" variant: dark base + tint at 15% opacity (dark mode) + let isDark = traitCollection.userInterfaceStyle == .dark + let tintColor = RosettaColors.avatarColor(for: colorIndex) + let baseColor = isDark ? UIColor(red: 0.102, green: 0.106, blue: 0.118, alpha: 1) : .white // #1A1B1E + let overlayAlpha: CGFloat = isDark ? 0.15 : 0.10 + avatarCircle.backgroundColor = baseColor.blended(with: tintColor, alpha: overlayAlpha) + // Text: shade-3 (text) in dark, shade-6 (tint) in light + let textColor = isDark + ? RosettaColors.avatarTextColor(for: colorIndex) + : RosettaColors.avatarColor(for: colorIndex) + avatarInitialLabel.textColor = textColor + } + } + + // Text + nameLabel.text = candidate.title + usernameLabel.text = " @\(candidate.username)" + + let nameSize = nameLabel.sizeThatFits(CGSize(width: w, height: h)) + let maxTextW = w - textLeftOffset - rightPadding + let textY = (h - nameSize.height) / 2 + let nameW = min(nameSize.width, maxTextW * 0.6) + nameLabel.frame = CGRect(x: textLeftOffset, y: textY, width: nameW, height: nameSize.height) + + let usernameSize = usernameLabel.sizeThatFits(CGSize(width: w, height: h)) + let remainingW = maxTextW - nameW + usernameLabel.frame = CGRect( + x: textLeftOffset + nameW, y: textY, + width: min(usernameSize.width, remainingW), height: usernameSize.height + ) + + // Separator (1px, Telegram: itemPlainSeparatorColor) + separatorLine.isHidden = !showSeparator + let sepH: CGFloat = 1.0 / UIScreen.main.scale + separatorLine.frame = CGRect(x: textLeftOffset, y: h - sepH, width: w - textLeftOffset, height: sepH) + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + UIView.animate(withDuration: highlighted ? 0 : 0.4) { + self.contentView.backgroundColor = highlighted + ? UIColor.white.withAlphaComponent(0.08) + : .clear + } + } +} + +// MARK: - Helpers + +private extension UIFont { + func rounded() -> UIFont { + guard let descriptor = fontDescriptor.withDesign(.rounded) else { return self } + return UIFont(descriptor: descriptor, size: 0) + } +} + +private extension UIColor { + func blended(with color: UIColor, alpha: CGFloat) -> UIColor { + var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0 + var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0 + getRed(&r1, green: &g1, blue: &b1, alpha: &a1) + color.getRed(&r2, green: &g2, blue: &b2, alpha: &a2) + return UIColor( + red: r1 * (1 - alpha) + r2 * alpha, + green: g1 * (1 - alpha) + g2 * alpha, + blue: b1 * (1 - alpha) + b2 * alpha, + alpha: 1 + ) + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift index a14af4c..043d86b 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift @@ -17,6 +17,8 @@ final class MessageCellActions { var onCall: (String) -> Void = { _ in } // peer public key var onGroupInviteTap: (String) -> Void = { _ in } // invite string var onGroupInviteOpen: (String) -> Void = { _ in } // group dialog key → navigate + var onMentionTap: (String) -> Void = { _ in } // username (without @) + var onAvatarTap: (String) -> Void = { _ in } // sender public key (group chats) // Multi-select var onEnterSelection: (ChatMessage) -> Void = { _ in } diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift index 9c11063..fb4e5ba 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift @@ -26,7 +26,9 @@ struct MessageCellView: View, Equatable { } var body: some View { + #if DEBUG let _ = PerformanceLogger.shared.track("chatDetail.rowEval") + #endif let outgoing = message.isFromMe(myPublicKey: currentPublicKey) let hasTail = position == .single || position == .bottom diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 5be5429..14867b5 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -559,6 +559,9 @@ final class NativeMessageCell: UICollectionViewCell { senderAvatarContainer.layer.cornerRadius = 18 // 36pt circle senderAvatarContainer.clipsToBounds = true senderAvatarContainer.isHidden = true + senderAvatarContainer.isUserInteractionEnabled = true + let avatarTap = UITapGestureRecognizer(target: self, action: #selector(handleAvatarTap)) + senderAvatarContainer.addGestureRecognizer(avatarTap) contentView.addSubview(senderAvatarContainer) // Match AvatarView: size * 0.38, bold, rounded design @@ -874,13 +877,29 @@ final class NativeMessageCell: UICollectionViewCell { duration: previewParts.duration, isOutgoing: layout.isOutgoing ) - let voiceId = voiceAtt.id - let voiceFileName = voiceAtt.preview.components(separatedBy: "::").last ?? "" + let isCurrentVoice = VoiceMessagePlayer.shared.currentMessageId == message.id + voiceView.updatePlaybackState( + isPlaying: isCurrentVoice && VoiceMessagePlayer.shared.isPlaying, + progress: isCurrentVoice ? CGFloat(VoiceMessagePlayer.shared.progress) : 0 + ) + let voiceAttachment = voiceAtt + let storedPassword = message.attachmentPassword + let playbackDuration = previewParts.duration + let playbackMessageId = message.id voiceView.onPlayTapped = { [weak self] in guard let self else { return } - let fileName = "voice_\(Int(previewParts.duration))s.m4a" - if let url = AttachmentCache.shared.fileURL(forAttachmentId: voiceId, fileName: fileName) { - VoiceMessagePlayer.shared.play(messageId: message.id, fileURL: url) + Task.detached(priority: .userInitiated) { + guard let playableURL = await Self.resolvePlayableVoiceURL( + attachment: voiceAttachment, + duration: playbackDuration, + storedPassword: storedPassword + ) else { + return + } + await MainActor.run { + guard self.message?.id == playbackMessageId else { return } + VoiceMessagePlayer.shared.play(messageId: playbackMessageId, fileURL: playableURL) + } } } fileIconView.isHidden = true @@ -1504,6 +1523,100 @@ final class NativeMessageCell: UICollectionViewCell { return (0, preview) } + private static func resolvePlayableVoiceURL( + attachment: MessageAttachment, + duration: TimeInterval, + storedPassword: String? + ) async -> URL? { + let fileName = "voice_\(Int(duration))s.m4a" + if let cached = playableVoiceURLFromCache(attachmentId: attachment.id, fileName: fileName) { + return cached + } + + guard let downloaded = await downloadVoiceData(attachment: attachment, storedPassword: storedPassword) else { + return nil + } + _ = AttachmentCache.shared.saveFile(downloaded, forAttachmentId: attachment.id, fileName: fileName) + return writePlayableVoiceTempFile( + data: downloaded, + attachmentId: attachment.id, + fileName: fileName + ) + } + + private static func playableVoiceURLFromCache(attachmentId: String, fileName: String) -> URL? { + guard let decrypted = AttachmentCache.shared.loadFileData( + forAttachmentId: attachmentId, + fileName: fileName + ) else { + return nil + } + return writePlayableVoiceTempFile(data: decrypted, attachmentId: attachmentId, fileName: fileName) + } + + private static func writePlayableVoiceTempFile(data: Data, attachmentId: String, fileName: String) -> URL? { + let safeFileName = fileName.replacingOccurrences(of: "/", with: "_") + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("voice_play_\(attachmentId)_\(safeFileName)") + try? FileManager.default.removeItem(at: tempURL) + do { + try data.write(to: tempURL, options: .atomic) + return tempURL + } catch { + return nil + } + } + + private static func downloadVoiceData(attachment: MessageAttachment, storedPassword: String?) async -> Data? { + let tag = attachment.effectiveDownloadTag + guard !tag.isEmpty else { return nil } + guard let storedPassword, !storedPassword.isEmpty else { return nil } + + do { + let encryptedData = try await TransportManager.shared.downloadFile( + tag: tag, + server: attachment.transportServer + ) + let encryptedString = String(decoding: encryptedData, as: UTF8.self) + let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword) + guard let decrypted = decryptAttachmentData(encryptedString: encryptedString, passwords: passwords) else { + return nil + } + return parseAttachmentFileData(decrypted) + } catch { + return nil + } + } + + private static func decryptAttachmentData(encryptedString: String, passwords: [String]) -> Data? { + let crypto = CryptoManager.shared + for password in passwords { + if let data = try? crypto.decryptWithPassword( + encryptedString, + password: password, + requireCompression: true + ) { + return data + } + } + for password in passwords { + if let data = try? crypto.decryptWithPassword(encryptedString, password: password) { + return data + } + } + return nil + } + + private static func parseAttachmentFileData(_ data: Data) -> Data { + if let string = String(data: data, encoding: .utf8), + string.hasPrefix("data:"), + let comma = string.firstIndex(of: ",") { + let payload = String(string[string.index(after: comma)...]) + return Data(base64Encoded: payload) ?? data + } + return data + } + private static func fileIcon(for fileName: String) -> String { let ext = (fileName as NSString).pathExtension.lowercased() switch ext { @@ -1640,12 +1753,25 @@ final class NativeMessageCell: UICollectionViewCell { return } let pointInText = gesture.location(in: textLabel) - guard let url = textLabel.textLayout?.linkAt(point: pointInText) else { return } - var finalURL = url - if finalURL.scheme == nil || finalURL.scheme?.isEmpty == true { - finalURL = URL(string: "https://\(url.absoluteString)") ?? url + // Check links first + if let url = textLabel.textLayout?.linkAt(point: pointInText) { + var finalURL = url + if finalURL.scheme == nil || finalURL.scheme?.isEmpty == true { + finalURL = URL(string: "https://\(url.absoluteString)") ?? url + } + UIApplication.shared.open(finalURL) + return } - UIApplication.shared.open(finalURL) + // Then check @mentions + if let username = textLabel.textLayout?.mentionAt(point: pointInText) { + actions?.onMentionTap(username) + return + } + } + + @objc private func handleAvatarTap() { + guard let key = message?.fromPublicKey, !key.isEmpty else { return } + actions?.onAvatarTap(key) } // MARK: - Context Menu (Telegram-style) @@ -2363,7 +2489,7 @@ final class NativeMessageCell: UICollectionViewCell { } photoBlurHashTasks[attachmentId] = Task { [weak self] in - let decoded = await Task.detached(priority: .utility) { + let decoded = await Task.detached(priority: .background) { UIImage.fromBlurHash(hash, width: 48, height: 48) }.value guard !Task.isCancelled else { return } diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 452ef79..3772ac6 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -1,6 +1,15 @@ import SwiftUI import UIKit +/// Cached reply/forward data parsed from JSON blob (immutable per message). +struct ReplyDataCacheEntry { + let replyName: String? + let replyText: String? + let replyMessageId: String? + let forwardSenderName: String? + let forwardSenderKey: String? +} + // MARK: - NativeMessageListController /// UICollectionView-based message list with inverted scroll (newest at bottom). @@ -19,6 +28,7 @@ final class NativeMessageListController: UIViewController { static let scrollButtonIconCanvas: CGFloat = 38 static let scrollButtonBaseTrailing: CGFloat = 17 static let scrollButtonCompactExtraTrailing: CGFloat = 18 + static let recordingScrollLift: CGFloat = 76 } @@ -45,6 +55,8 @@ final class NativeMessageListController: UIViewController { var onScrollToBottomVisibilityChange: ((Bool) -> Void)? var onPaginationTrigger: (() -> Void)? + var onBottomPaginationTrigger: (() -> Void)? + var hasNewerMessages: Bool = false var onTapBackground: (() -> Void)? var onComposerHeightChange: ((CGFloat) -> Void)? var onKeyboardDidHide: (() -> Void)? @@ -139,6 +151,9 @@ final class NativeMessageListController: UIViewController { // MARK: - Layout Cache (Telegram asyncLayout pattern) + /// Generation counter for discarding stale async layout results. + private var layoutGeneration: UInt64 = 0 + /// Cache: messageId → pre-calculated layout from background thread. /// All frame rects computed once, applied on main thread (just sets frames). private var layoutCache: [String: MessageCellLayout] = [:] @@ -147,6 +162,15 @@ final class NativeMessageListController: UIViewController { /// Eliminates double CoreText computation (measure + render → measure once, render from cache). private var textLayoutCache: [String: CoreTextTextLayout] = [:] + /// Cache: messageId → parsed reply/forward data. Reply blobs are immutable after creation, + /// so JSON decode only happens once per message instead of every cell configure. + private var replyDataCache: [String: ReplyDataCacheEntry] = [:] + + // MARK: - Skeleton Loading + + private var skeletonView: NativeSkeletonView? + private var isShowingSkeleton = false + // MARK: - Init init(config: Config) { @@ -200,10 +224,20 @@ final class NativeMessageListController: UIViewController { let members = try? await GroupService.shared.requestMembers( groupDialogKey: self.config.opponentPublicKey ) - if let adminKey = members?.first, !adminKey.isEmpty { - self.config.groupAdminKey = adminKey - self.calculateLayouts() - self.collectionView.reloadData() + if let members, !members.isEmpty { + // Cache members for mention autocomplete + GroupRepository.shared.updateMemberCache( + account: account, + groupDialogKey: self.config.opponentPublicKey, + memberKeys: members + ) + if let adminKey = members.first, !adminKey.isEmpty { + self.config.groupAdminKey = adminKey + self.calculateLayouts() + self.collectionView.reloadData() + } + // Trigger mention preload now that cache is populated + self.composerView?.preloadMentionMembers() } } } @@ -218,6 +252,36 @@ final class NativeMessageListController: UIViewController { self.calculateLayouts() self.refreshAllMessageCells() } + + // Show skeleton placeholder while messages load from DB + if messages.isEmpty { + showSkeleton() + } + } + + // MARK: - Skeleton + + private func showSkeleton() { + guard skeletonView == nil else { return } + let skeleton = NativeSkeletonView() + skeleton.frame = view.bounds + skeleton.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(skeleton) + skeletonView = skeleton + isShowingSkeleton = true + } + + private func hideSkeletonAnimated() { + guard let skeleton = skeletonView else { return } + isShowingSkeleton = false + skeletonView = nil + skeleton.animateOut { + skeleton.removeFromSuperview() + } + // Fallback: force remove after 1s if animation didn't complete + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak skeleton] in + skeleton?.removeFromSuperview() + } } @objc private func handleAvatarDidUpdate() { @@ -234,6 +298,8 @@ final class NativeMessageListController: UIViewController { composerView?.isFocusBlocked = true view.endEditing(true) onComposerFocusChange?(false) + dateHideTimer?.invalidate() + dateHideTimer = nil } override func viewDidAppear(_ animated: Bool) { @@ -286,6 +352,7 @@ final class NativeMessageListController: UIViewController { collectionView.translatesAutoresizingMaskIntoConstraints = false collectionView.backgroundColor = .clear collectionView.delegate = self + collectionView.prefetchDataSource = self collectionView.keyboardDismissMode = .interactive collectionView.showsVerticalScrollIndicator = false collectionView.showsHorizontalScrollIndicator = false @@ -334,45 +401,41 @@ final class NativeMessageListController: UIViewController { cell.apply(layout: layout) } - // Parse reply data for quote display - let replyAtt = msg.attachments.first { $0.type == .messages } - var replyName: String? - var replyText: String? - var replyMessageId: String? - var forwardSenderName: String? - var forwardSenderKey: String? - - if let att = replyAtt { - if let data = att.blob.data(using: .utf8), - let replies = try? JSONDecoder().decode([ReplyMessageData].self, from: data), - let first = replies.first { - let senderKey = first.publicKey - let name: String - if senderKey == self.config.currentPublicKey { - name = "You" - } else if senderKey == self.config.opponentPublicKey { - name = self.config.opponentTitle.isEmpty - ? String(senderKey.prefix(8)) + "…" - : self.config.opponentTitle - } else { - name = DialogRepository.shared.dialogs[senderKey]?.opponentTitle - ?? String(senderKey.prefix(8)) + "…" - } - - let displayText = MessageCellLayout.isGarbageOrEncrypted(msg.text) ? "" : msg.text - if displayText.isEmpty { - // Forward - forwardSenderName = name - forwardSenderKey = senderKey - } else { - // Reply quote - replyName = name - let rawReplyMsg = first.message.isEmpty ? "Photo" : first.message - replyText = EmojiParser.replaceShortcodes(in: rawReplyMsg) - replyMessageId = first.message_id - } + // Parse reply data for quote display (cached — JSON decode once per message) + let replyEntry: ReplyDataCacheEntry? = { + guard msg.attachments.contains(where: { $0.type == .messages }) else { return nil } + if let cached = self.replyDataCache[msg.id] { return cached } + guard let att = msg.attachments.first(where: { $0.type == .messages }), + let data = att.blob.data(using: .utf8), + let replies = try? JSONDecoder().decode([ReplyMessageData].self, from: data), + let first = replies.first else { + let empty = ReplyDataCacheEntry(replyName: nil, replyText: nil, replyMessageId: nil, forwardSenderName: nil, forwardSenderKey: nil) + self.replyDataCache[msg.id] = empty + return empty } - } + let senderKey = first.publicKey + let name: String + if senderKey == self.config.currentPublicKey { + name = "You" + } else if senderKey == self.config.opponentPublicKey { + name = self.config.opponentTitle.isEmpty + ? String(senderKey.prefix(8)) + "…" + : self.config.opponentTitle + } else { + name = DialogRepository.shared.dialogs[senderKey]?.opponentTitle + ?? String(senderKey.prefix(8)) + "…" + } + let displayText = MessageCellLayout.isGarbageOrEncrypted(msg.text) ? "" : msg.text + let entry: ReplyDataCacheEntry + if displayText.isEmpty { + entry = ReplyDataCacheEntry(replyName: nil, replyText: nil, replyMessageId: nil, forwardSenderName: name, forwardSenderKey: senderKey) + } else { + let rawReplyMsg = first.message.isEmpty ? "Photo" : first.message + entry = ReplyDataCacheEntry(replyName: name, replyText: EmojiParser.replaceShortcodes(in: rawReplyMsg), replyMessageId: first.message_id, forwardSenderName: nil, forwardSenderKey: nil) + } + self.replyDataCache[msg.id] = entry + return entry + }() cell.isSavedMessages = self.config.isSavedMessages cell.isSystemAccount = self.config.isSystemAccount @@ -381,11 +444,11 @@ final class NativeMessageListController: UIViewController { timestamp: self.formatTimestamp(msg.timestamp), textLayout: self.textLayoutCache[msg.id], actions: self.config.actions, - replyName: replyName, - replyText: replyText, - replyMessageId: replyMessageId, - forwardSenderName: forwardSenderName, - forwardSenderKey: forwardSenderKey + replyName: replyEntry?.replyName, + replyText: replyEntry?.replyText, + replyMessageId: replyEntry?.replyMessageId, + forwardSenderName: replyEntry?.forwardSenderName, + forwardSenderKey: replyEntry?.forwardSenderKey ) // Multi-select: apply selection state on cell (re)configuration @@ -444,6 +507,11 @@ final class NativeMessageListController: UIViewController { private func performSetupComposer() { let composer = ComposerView(frame: .zero) composer.delegate = self + composer.isGroupChat = config.isGroupChat + composer.groupDialogKey = config.opponentPublicKey + if config.isGroupChat { + composer.preloadMentionMembers() + } composer.translatesAutoresizingMaskIntoConstraints = false view.addSubview(composer) @@ -550,7 +618,14 @@ final class NativeMessageListController: UIViewController { updateScrollToBottomBadge() } + var onJumpToBottom: (() -> Void)? + @objc private func scrollToBottomTapped() { + // If detached from bottom (sliding window trimmed newest), reload latest first + if hasNewerMessages { + onJumpToBottom?() + return + } scrollToBottom(animated: true) onScrollToBottomVisibilityChange?(true) } @@ -579,7 +654,17 @@ final class NativeMessageListController: UIViewController { let safeBottom = view.safeAreaInsets.bottom let compactShift = safeBottom <= 32 ? UIConstants.scrollButtonCompactExtraTrailing : 0 scrollToBottomTrailingConstraint?.constant = -(UIConstants.scrollButtonBaseTrailing + compactShift) - scrollToBottomBottomConstraint?.constant = -(lastComposerHeight + 4) + scrollToBottomBottomConstraint?.constant = -(lastComposerHeight + 4 + recordingAwareScrollLift()) + } + + private func recordingAwareScrollLift() -> CGFloat { + guard let composer = composerView else { return 0 } + switch composer.recordingFlowState { + case .recordingLocked, .waitingForPreview, .draftPreview: + return UIConstants.recordingScrollLift + default: + return 0 + } } private func updateScrollToBottomBadge() { @@ -947,6 +1032,11 @@ final class NativeMessageListController: UIViewController { /// Called from SwiftUI when messages array changes. func update(messages: [ChatMessage], animated: Bool = false) { + // Hide skeleton on first message arrival + if isShowingSkeleton && !messages.isEmpty { + hideSkeletonAnimated() + } + let oldIds = Set(self.messages.map(\.id)) let oldNewestId = self.messages.last?.id @@ -975,21 +1065,58 @@ final class NativeMessageListController: UIViewController { self.messages = messages - // Recalculate ALL layouts — BubblePosition depends on neighbors in the FULL - // array, so inserting one message changes the previous message's position/tail. - // CoreText measurement is ~0.1ms per message; 50 msgs ≈ 5ms — well under 16ms. - calculateLayouts() + // Evict caches for messages no longer in the sliding window + if !layoutCache.isEmpty { + let currentIds = Set(messages.map(\.id)) + layoutCache = layoutCache.filter { currentIds.contains($0.key) } + textLayoutCache = textLayoutCache.filter { currentIds.contains($0.key) } + replyDataCache = replyDataCache.filter { currentIds.contains($0.key) } + } + + // Layout calculation: sync for first load, async for subsequent updates. + if layoutCache.isEmpty { + // First load: synchronous to avoid blank cells + calculateLayouts() + } else if !newIds.isEmpty && newIds.count <= 20 { + // Incremental: only new messages + neighbors, on background + var dirtyIds = newIds + for i in messages.indices where newIds.contains(messages[i].id) { + if i > 0 { dirtyIds.insert(messages[i - 1].id) } + if i < messages.count - 1 { dirtyIds.insert(messages[i + 1].id) } + } + calculateLayoutsAsync(dirtyIds: dirtyIds) + } else { + // Bulk update (pagination, sync): async full recalculation + calculateLayoutsAsync() + } var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) let itemIds = messages.reversed().map(\.id) snapshot.appendItems(itemIds) - // Reconfigure existing cells whose BubblePosition/tail may have changed. - // Without this, DiffableDataSource reuses stale cells (wrong corners/tail). - let existingItems = itemIds.filter { oldIds.contains($0) } - if !existingItems.isEmpty { - snapshot.reconfigureItems(existingItems) + // Reconfigure only neighbor cells whose BubblePosition/tail may have changed. + // BubblePosition depends only on immediate predecessor/successor (shouldMerge), + // so we only need to reconfigure messages adjacent to each new/changed message. + if !newIds.isEmpty && newIds.count <= 20 { + var neighborsToReconfigure = Set() + for i in messages.indices { + if newIds.contains(messages[i].id) { + if i > 0 { neighborsToReconfigure.insert(messages[i - 1].id) } + if i < messages.count - 1 { neighborsToReconfigure.insert(messages[i + 1].id) } + } + } + // Only reconfigure old cells that were already in the snapshot + let toReconfigure = neighborsToReconfigure.filter { oldIds.contains($0) } + if !toReconfigure.isEmpty { + snapshot.reconfigureItems(Array(toReconfigure)) + } + } else { + // Bulk update (pagination, sync): reconfigure all existing cells + let existingItems = itemIds.filter { oldIds.contains($0) } + if !existingItems.isEmpty { + snapshot.reconfigureItems(existingItems) + } } dataSource.apply(snapshot, animatingDifferences: false) @@ -1088,10 +1215,9 @@ final class NativeMessageListController: UIViewController { // MARK: - Layout Calculation (Telegram asyncLayout pattern) - /// Recalculate layouts for ALL messages using the full array. - /// BubblePosition is computed from neighbors — partial recalculation produces - /// stale positions (wrong corners, missing tails on live insertion). - private func calculateLayouts() { + /// Recalculate layouts for messages. When `dirtyIds` is provided, only those + /// messages are recalculated (incremental mode). Otherwise recalculates all. + private func calculateLayouts(dirtyIds: Set? = nil) { guard !messages.isEmpty else { layoutCache.removeAll() textLayoutCache.removeAll() @@ -1107,12 +1233,50 @@ final class NativeMessageListController: UIViewController { opponentTitle: config.opponentTitle, isGroupChat: config.isGroupChat, groupAdminKey: config.groupAdminKey, - isDarkMode: isDark + isDarkMode: isDark, + dirtyIds: dirtyIds, + existingLayouts: dirtyIds != nil ? layoutCache : nil, + existingTextLayouts: dirtyIds != nil ? textLayoutCache : nil ) layoutCache = layouts textLayoutCache = textLayouts } + /// Async layout calculation on background thread via LayoutEngine actor. + /// Results applied on main thread; stale results discarded via generation counter. + private func calculateLayoutsAsync(dirtyIds: Set? = nil) { + guard !messages.isEmpty else { + layoutCache.removeAll() + textLayoutCache.removeAll() + return + } + let themeMode = UserDefaults.standard.string(forKey: "rosetta_theme_mode") ?? "dark" + let isDark = themeMode != "light" + + let request = LayoutEngine.LayoutRequest( + messages: messages, + maxBubbleWidth: config.maxBubbleWidth, + currentPublicKey: config.currentPublicKey, + opponentPublicKey: config.opponentPublicKey, + opponentTitle: config.opponentTitle, + isGroupChat: config.isGroupChat, + groupAdminKey: config.groupAdminKey, + isDarkMode: isDark, + dirtyIds: dirtyIds, + existingLayouts: dirtyIds != nil ? layoutCache : nil, + existingTextLayouts: dirtyIds != nil ? textLayoutCache : nil + ) + + Task { @MainActor [weak self] in + let result = await LayoutEngine.shared.calculate(request) + guard let self, result.generation >= self.layoutGeneration else { return } + self.layoutGeneration = result.generation + self.layoutCache = result.layouts + self.textLayoutCache = result.textLayouts + self.reconfigureVisibleCells() + } + } + // MARK: - Inset Management /// Update content insets for composer overlap + keyboard. @@ -1370,14 +1534,50 @@ extension NativeMessageListController: UICollectionViewDelegate { setScrollToBottomVisible(!isAtBottom) } + // Top pagination (older messages) — inverted scroll: visual top = content bottom + // Telegram parity: prefetch well ahead of the edge (~4 cells at 500pt). let offsetFromTop = scrollView.contentSize.height - scrollView.contentOffset.y - scrollView.bounds.height - if offsetFromTop < 200, hasMoreMessages { + if offsetFromTop < 500, hasMoreMessages { onPaginationTrigger?() } + + // Bottom pagination (newer messages) — reuse offsetFromBottom from above + if offsetFromBottom < 500, hasNewerMessages { + onBottomPaginationTrigger?() + } } } +// MARK: - UICollectionViewDataSourcePrefetching + +extension NativeMessageListController: UICollectionViewDataSourcePrefetching { + + /// Telegram parity: proactively warm encrypted disk → memory image cache + /// for upcoming cells. Eliminates placeholder→image flash during scroll. + func collectionView(_ collectionView: UICollectionView, + prefetchItemsAt indexPaths: [IndexPath]) { + for indexPath in indexPaths { + guard let messageId = dataSource.itemIdentifier(for: indexPath), + let idx = messages.firstIndex(where: { $0.id == messageId }) else { continue } + let msg = messages[idx] + for att in msg.attachments where att.type == .image { + let attId = att.id + // Skip if already in memory cache + if AttachmentCache.shared.cachedImage(forAttachmentId: attId) != nil { continue } + Task.detached(priority: .utility) { + let _ = AttachmentCache.shared.loadImage(forAttachmentId: attId) + } + } + } + } + + func collectionView(_ collectionView: UICollectionView, + cancelPrefetchingForItemsAt indexPaths: [IndexPath]) { + // No-op: disk-to-memory prefetch is cheap to complete + } +} + // MARK: - ComposerViewDelegate extension NativeMessageListController: ComposerViewDelegate { @@ -1430,9 +1630,14 @@ extension NativeMessageListController: ComposerViewDelegate { func composerDidStartRecording(_ composer: ComposerView) { // Recording started — handled by ComposerView internally + updateScrollToBottomButtonConstraints() + view.layoutIfNeeded() } func composerDidFinishRecording(_ composer: ComposerView, sendImmediately: Bool) { + updateScrollToBottomButtonConstraints() + view.layoutIfNeeded() + guard sendImmediately, let url = composer.lastRecordedURL, let data = try? Data(contentsOf: url) else { return } @@ -1461,10 +1666,14 @@ extension NativeMessageListController: ComposerViewDelegate { func composerDidCancelRecording(_ composer: ComposerView) { // Recording cancelled — no action needed + updateScrollToBottomButtonConstraints() + view.layoutIfNeeded() } func composerDidLockRecording(_ composer: ComposerView) { // Recording locked — UI handled by ComposerView + updateScrollToBottomButtonConstraints() + view.layoutIfNeeded() } private func animateVoiceSendTransition(source: VoiceSendTransitionSource, messageId: String) { @@ -1597,9 +1806,12 @@ struct NativeMessageListView: UIViewControllerRepresentable { var emptyChatInfo: EmptyChatInfo? var scrollToMessageId: String? var shouldScrollToBottom: Bool = false - @Binding var scrollToBottomRequested: Bool + var scrollToBottomTrigger: UInt = 0 var onAtBottomChange: ((Bool) -> Void)? var onPaginate: (() -> Void)? + var onBottomPaginate: (() -> Void)? + var onJumpToBottom: (() -> Void)? + var hasNewerMessagesFlag: Bool = false var onTapBackground: (() -> Void)? var onNewMessageAutoScroll: (() -> Void)? var onComposerHeightChange: ((CGFloat) -> Void)? @@ -1675,6 +1887,7 @@ struct NativeMessageListView: UIViewControllerRepresentable { } controller.hasMoreMessages = hasMoreMessages + controller.hasNewerMessages = hasNewerMessagesFlag wireCallbacks(controller, context: context) @@ -1719,11 +1932,11 @@ struct NativeMessageListView: UIViewControllerRepresentable { controller.reconfigureVisibleCells() } - // Scroll-to-bottom button request - if scrollToBottomRequested { + // Scroll-to-bottom button request (counter avoids Binding write-back cycle) + if scrollToBottomTrigger != coordinator.lastScrollTrigger { + coordinator.lastScrollTrigger = scrollToBottomTrigger DispatchQueue.main.async { controller.scrollToBottom(animated: true) - scrollToBottomRequested = false } } @@ -1754,6 +1967,8 @@ struct NativeMessageListView: UIViewControllerRepresentable { DispatchQueue.main.async { onAtBottomChange?(isAtBottom) } } controller.onPaginationTrigger = { onPaginate?() } + controller.onBottomPaginationTrigger = { onBottomPaginate?() } + controller.onJumpToBottom = { onJumpToBottom?() } controller.onTapBackground = { onTapBackground?() } controller.onComposerHeightChange = { h in DispatchQueue.main.async { onComposerHeightChange?(h) } @@ -1809,6 +2024,7 @@ struct NativeMessageListView: UIViewControllerRepresentable { var lastMessageFingerprint: String = "" var lastNewestMessageId: String? var lastScrollTargetId: String? + var lastScrollTrigger: UInt = 0 var isAtBottom: Bool = true } } diff --git a/Rosetta/Features/Chats/ChatDetail/NativeSkeletonView.swift b/Rosetta/Features/Chats/ChatDetail/NativeSkeletonView.swift new file mode 100644 index 0000000..326c5c0 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/NativeSkeletonView.swift @@ -0,0 +1,283 @@ +import UIKit + +// MARK: - NativeSkeletonView + +/// Telegram-quality skeleton loading for chat message list. +/// Shows 14 incoming bubble placeholders stacked from bottom up with shimmer animation. +/// Telegram parity: ChatLoadingNode.swift — 14 bubbles, shimmer via screenBlendMode. +final class NativeSkeletonView: UIView { + + // MARK: - Telegram-Exact Dimensions + + private static let shortHeight: CGFloat = 71 + private static let tallHeight: CGFloat = 93 + private static let avatarSize: CGFloat = 38 + private static let avatarLeftInset: CGFloat = 8 + private static let bubbleLeftInset: CGFloat = 54 // avatar + spacing + private static let verticalGap: CGFloat = 4 // Small gap between skeleton bubbles + private static let initialBottomOffset: CGFloat = 5 + + /// Telegram-exact width fractions and heights for 14 skeleton bubbles. + private static let bubbleSpecs: [(widthFrac: CGFloat, height: CGFloat)] = [ + (0.47, tallHeight), (0.58, tallHeight), (0.69, tallHeight), (0.47, tallHeight), + (0.58, shortHeight), (0.36, tallHeight), (0.47, tallHeight), (0.36, shortHeight), + (0.58, tallHeight), (0.69, tallHeight), (0.58, tallHeight), (0.36, shortHeight), + (0.47, tallHeight), (0.58, tallHeight), + ] + + // MARK: - Shimmer Parameters (Telegram-exact) + + private static let shimmerDuration: CFTimeInterval = 1.6 + private static let shimmerEffectSize: CGFloat = 280 + private static let shimmerOpacity: CGFloat = 0.14 + private static let borderShimmerEffectSize: CGFloat = 320 + private static let borderShimmerOpacity: CGFloat = 0.35 + + // MARK: - Subviews + + private let containerView = UIView() + private var bubbleLayers: [CAShapeLayer] = [] + private var avatarLayers: [CAShapeLayer] = [] + private var shimmerLayer: CALayer? + private var borderShimmerLayer: CALayer? + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Setup + + private func setup() { + backgroundColor = .clear + isUserInteractionEnabled = false + + containerView.frame = bounds + containerView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + addSubview(containerView) + } + + // MARK: - Layout + + override func layoutSubviews() { + super.layoutSubviews() + containerView.frame = bounds + rebuildBubbles() + } + + private func rebuildBubbles() { + // Remove old layers + bubbleLayers.forEach { $0.removeFromSuperlayer() } + avatarLayers.forEach { $0.removeFromSuperlayer() } + bubbleLayers.removeAll() + avatarLayers.removeAll() + shimmerLayer?.removeFromSuperlayer() + borderShimmerLayer?.removeFromSuperlayer() + + let width = bounds.width + let height = bounds.height + guard width > 0, height > 0 else { return } + + let themeMode = UserDefaults.standard.string(forKey: "rosetta_theme_mode") ?? "dark" + let isDark = themeMode != "light" + let bubbleColor = isDark + ? UIColor.gray.withAlphaComponent(0.08) + : UIColor.gray.withAlphaComponent(0.10) + let avatarColor = isDark + ? UIColor.gray.withAlphaComponent(0.06) + : UIColor.gray.withAlphaComponent(0.08) + + // Build mask from all bubbles combined + let combinedMaskPath = CGMutablePath() + let combinedBorderMaskPath = CGMutablePath() + + // Stack from bottom up + var y = height - Self.initialBottomOffset + let metrics = BubbleMetrics.telegram() + + for spec in Self.bubbleSpecs { + let bubbleWidth = floor(spec.widthFrac * width) + let bubbleHeight = spec.height + y -= bubbleHeight + + guard y > -bubbleHeight else { break } // Off screen + + let bubbleFrame = CGRect( + x: Self.bubbleLeftInset, + y: y, + width: bubbleWidth, + height: bubbleHeight + ) + + // Bubble shape layer (visible fill) + let bubblePath = BubbleGeometryEngine.makeBezierPath( + in: CGRect(origin: .zero, size: bubbleFrame.size), + mergeType: .none, + outgoing: false, + metrics: metrics + ) + + let bubbleLayer = CAShapeLayer() + bubbleLayer.frame = bubbleFrame + bubbleLayer.path = bubblePath.cgPath + bubbleLayer.fillColor = bubbleColor.cgColor + containerView.layer.addSublayer(bubbleLayer) + bubbleLayers.append(bubbleLayer) + + // Add to combined mask for shimmer clipping + var translateTransform = CGAffineTransform(translationX: bubbleFrame.minX, y: bubbleFrame.minY) + if let translatedPath = bubblePath.cgPath.copy(using: &translateTransform) { + combinedMaskPath.addPath(translatedPath) + } + // Border mask (stroke only) + let borderStrokePath = bubblePath.cgPath.copy( + strokingWithWidth: 2, + lineCap: CGLineCap.round, + lineJoin: CGLineJoin.round, + miterLimit: 10 + ) + if let translatedBorderPath = borderStrokePath.copy(using: &translateTransform) { + combinedBorderMaskPath.addPath(translatedBorderPath) + } + + // Avatar circle + let avatarFrame = CGRect( + x: Self.avatarLeftInset, + y: y + bubbleHeight - Self.avatarSize, // bottom-aligned with bubble + width: Self.avatarSize, + height: Self.avatarSize + ) + let avatarLayer = CAShapeLayer() + avatarLayer.frame = avatarFrame + avatarLayer.path = UIBezierPath(ovalIn: CGRect(origin: .zero, size: avatarFrame.size)).cgPath + avatarLayer.fillColor = avatarColor.cgColor + avatarLayer.strokeColor = bubbleColor.cgColor + avatarLayer.lineWidth = 1 + containerView.layer.addSublayer(avatarLayer) + avatarLayers.append(avatarLayer) + + // Add avatar to combined mask + let avatarCircle = CGPath(ellipseIn: avatarFrame, transform: nil) + combinedMaskPath.addPath(avatarCircle) + + y -= Self.verticalGap + } + + // Content shimmer layer with bubble mask + let contentShimmer = makeShimmerLayer( + size: bounds.size, + effectSize: Self.shimmerEffectSize, + color: UIColor.white.withAlphaComponent(Self.shimmerOpacity), + duration: Self.shimmerDuration + ) + let contentMask = CAShapeLayer() + contentMask.path = combinedMaskPath + contentShimmer.mask = contentMask + containerView.layer.addSublayer(contentShimmer) + shimmerLayer = contentShimmer + + // Border shimmer layer + let borderShimmer = makeShimmerLayer( + size: bounds.size, + effectSize: Self.borderShimmerEffectSize, + color: UIColor.white.withAlphaComponent(Self.borderShimmerOpacity), + duration: Self.shimmerDuration + ) + let borderMask = CAShapeLayer() + borderMask.path = combinedBorderMaskPath + borderShimmer.mask = borderMask + containerView.layer.addSublayer(borderShimmer) + borderShimmerLayer = borderShimmer + } + + // MARK: - Shimmer Layer Factory + + private func makeShimmerLayer( + size: CGSize, + effectSize: CGFloat, + color: UIColor, + duration: CFTimeInterval + ) -> CALayer { + let container = CALayer() + container.frame = CGRect(origin: .zero, size: size) + container.compositingFilter = "screenBlendMode" + + // Gradient image (horizontal: transparent → color → transparent) + let gradientLayer = CAGradientLayer() + gradientLayer.colors = [ + color.withAlphaComponent(0).cgColor, + color.cgColor, + color.withAlphaComponent(0).cgColor, + ] + gradientLayer.locations = [0.0, 0.5, 1.0] + gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) + gradientLayer.frame = CGRect(x: -effectSize, y: 0, width: effectSize, height: size.height) + + container.addSublayer(gradientLayer) + + // Animate position.x from -effectSize to size.width + effectSize + let animation = CABasicAnimation(keyPath: "position.x") + animation.fromValue = -effectSize / 2 + animation.toValue = size.width + effectSize / 2 + animation.duration = duration + animation.timingFunction = CAMediaTimingFunction(name: .easeOut) + animation.repeatCount = .infinity + gradientLayer.add(animation, forKey: "shimmer") + + return container + } + + // MARK: - Staggered Fade Out + + func animateOut(completion: @escaping () -> Void) { + let totalLayers = bubbleLayers.count + avatarLayers.count + guard totalLayers > 0 else { + completion() + return + } + + // Fade out shimmer first (CALayer — use CATransaction, not UIView.animate) + CATransaction.begin() + CATransaction.setAnimationDuration(0.15) + shimmerLayer?.opacity = 0 + borderShimmerLayer?.opacity = 0 + CATransaction.commit() + + // Staggered fade-out per bubble (bottom = index 0 fades first) + for (i, bubbleLayer) in bubbleLayers.enumerated() { + let delay = Double(i) * 0.02 + let fade = CABasicAnimation(keyPath: "opacity") + fade.fromValue = 1.0 + fade.toValue = 0.0 + fade.duration = 0.2 + fade.beginTime = CACurrentMediaTime() + delay + fade.fillMode = .forwards + fade.isRemovedOnCompletion = false + bubbleLayer.add(fade, forKey: "fadeOut") + } + + for (i, avatarLayer) in avatarLayers.enumerated() { + let delay = Double(i) * 0.02 + let fade = CABasicAnimation(keyPath: "opacity") + fade.fromValue = 1.0 + fade.toValue = 0.0 + fade.duration = 0.2 + fade.beginTime = CACurrentMediaTime() + delay + fade.fillMode = .forwards + fade.isRemovedOnCompletion = false + avatarLayer.add(fade, forKey: "fadeOut") + } + + // Call completion after all animations complete + let totalDuration = Double(bubbleLayers.count) * 0.02 + 0.2 + DispatchQueue.main.asyncAfter(deadline: .now() + totalDuration) { + completion() + } + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift b/Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift index 89df767..8fabd84 100644 --- a/Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift +++ b/Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift @@ -1,39 +1,69 @@ -import QuartzCore +import Lottie import UIKit // MARK: - RecordingLockView /// Lock indicator shown above mic button during voice recording. -/// Telegram parity from TGModernConversationInputMicButton.m: -/// - Frame: 40×72pt, positioned 122pt above mic center -/// - Padlock icon (CAShapeLayer) + upward arrow -/// - Spring entry: damping 0.55, duration 0.5s -/// - Lockness progress: arrow fades, panel shrinks +/// Telegram parity contract: +/// - Wrapper frame: 40x72 +/// - Lock morph: y = 40 * lockness, height = 72 - 32 * lockness +/// - Lock icon shift: -11 * lockness, arrow shift: -39 * lockness +/// - Stop mode: clean 40x40 circle, delayed fade-in (0.56s) final class RecordingLockView: UIView { + private enum VisualState { + case lock + case stop + } + // MARK: - Layout Constants (Telegram exact) private let panelWidth: CGFloat = 40 private let panelFullHeight: CGFloat = 72 - private let panelLockedHeight: CGFloat = 40 // 72 - 32 - private let verticalOffset: CGFloat = 122 // above mic center - private let cornerRadius: CGFloat = 20 + private let panelLockedHeight: CGFloat = 40 + private let verticalOffset: CGFloat = 122 // MARK: - Subviews - private let backgroundView = UIView() - private let lockIcon = CAShapeLayer() - private let arrowLayer = CAShapeLayer() - private let stopButton = UIButton(type: .system) + private let panelGlassView = TelegramGlassUIView(frame: .zero) + private let panelBorderView = UIView() + private let lockAnimationContainer = UIView() + private let idleLockView = LottieAnimationView() + private let lockingView = LottieAnimationView() + private let lockFallbackGlyphView = UIImageView() + private let lockArrowView = UIImageView() + + private let stopButton = UIButton(type: .custom) + private let stopGlassView = TelegramGlassUIView(frame: .zero) + private let stopBorderView = UIView() + private let stopGlyphView = UIImageView() private var onStopTap: (() -> Void)? + // MARK: - State + + private var currentLockness: CGFloat = 0 + private var visualState: VisualState = .lock + + private var usesComponentVisuals: Bool { + if #available(iOS 26.0, *) { + return true + } + return false + } + + private var isShowingStopButton: Bool { + visualState == .stop + } + // MARK: - Init override init(frame: CGRect) { super.init(frame: frame) - isUserInteractionEnabled = false - setupBackground() - setupLockIcon() + clipsToBounds = false + isUserInteractionEnabled = true + + setupPanel() + setupLockAnimations() setupArrow() setupStopButton() } @@ -43,115 +73,199 @@ final class RecordingLockView: UIView { // MARK: - Setup - private func setupBackground() { - backgroundView.backgroundColor = UIColor(white: 0.15, alpha: 0.9) - backgroundView.layer.cornerRadius = cornerRadius - backgroundView.layer.cornerCurve = .continuous - backgroundView.layer.borderWidth = 1.0 / UIScreen.main.scale - backgroundView.layer.borderColor = UIColor(white: 0.3, alpha: 0.5).cgColor - addSubview(backgroundView) + private func setupPanel() { + panelGlassView.isUserInteractionEnabled = false + panelGlassView.fixedCornerRadius = panelFullHeight / 2.0 + addSubview(panelGlassView) + + panelBorderView.isUserInteractionEnabled = false + panelBorderView.backgroundColor = .clear + panelBorderView.layer.cornerCurve = .continuous + panelBorderView.layer.borderWidth = 1.0 / UIScreen.main.scale + addSubview(panelBorderView) } - private func setupLockIcon() { - // Simple padlock: body (rounded rect) + shackle (arc) - let path = UIBezierPath() + private func setupLockAnimations() { + lockAnimationContainer.isUserInteractionEnabled = false + addSubview(lockAnimationContainer) - // Shackle (arc above body) - let shackleW: CGFloat = 10 - let shackleH: CGFloat = 8 - let bodyTop: CGFloat = 10 - let centerX: CGFloat = panelWidth / 2 - path.move(to: CGPoint(x: centerX - shackleW / 2, y: bodyTop)) - path.addLine(to: CGPoint(x: centerX - shackleW / 2, y: bodyTop - shackleH + 3)) - path.addCurve( - to: CGPoint(x: centerX + shackleW / 2, y: bodyTop - shackleH + 3), - controlPoint1: CGPoint(x: centerX - shackleW / 2, y: bodyTop - shackleH - 2), - controlPoint2: CGPoint(x: centerX + shackleW / 2, y: bodyTop - shackleH - 2) - ) - path.addLine(to: CGPoint(x: centerX + shackleW / 2, y: bodyTop)) + if let animation = LottieAnimation.named(VoiceRecordingLottieAsset.lockWait.rawValue) { + idleLockView.animation = animation + } + idleLockView.backgroundBehavior = .pauseAndRestore + idleLockView.loopMode = .autoReverse + idleLockView.contentMode = .scaleAspectFit + idleLockView.isUserInteractionEnabled = false + lockAnimationContainer.addSubview(idleLockView) - lockIcon.path = path.cgPath - lockIcon.strokeColor = UIColor.white.cgColor - lockIcon.fillColor = UIColor.clear.cgColor - lockIcon.lineWidth = 1.5 - lockIcon.lineCap = .round + if let animation = LottieAnimation.named(VoiceRecordingLottieAsset.lock.rawValue) { + lockingView.animation = animation + } + lockingView.backgroundBehavior = .pauseAndRestore + lockingView.loopMode = .playOnce + lockingView.contentMode = .scaleAspectFit + lockingView.currentProgress = 0 + lockingView.isHidden = true + lockingView.isUserInteractionEnabled = false + lockAnimationContainer.addSubview(lockingView) - // Body (rounded rect below shackle) - let bodyW: CGFloat = 14 - let bodyH: CGFloat = 10 - let bodyPath = UIBezierPath( - roundedRect: CGRect( - x: centerX - bodyW / 2, - y: bodyTop, - width: bodyW, - height: bodyH - ), - cornerRadius: 2 - ) - let bodyLayer = CAShapeLayer() - bodyLayer.path = bodyPath.cgPath - bodyLayer.fillColor = UIColor.white.cgColor - layer.addSublayer(bodyLayer) - layer.addSublayer(lockIcon) + let lockConfig = UIImage.SymbolConfiguration(pointSize: 24, weight: .regular) + lockFallbackGlyphView.image = UIImage(systemName: "lock", withConfiguration: lockConfig) + lockFallbackGlyphView.contentMode = .center + lockFallbackGlyphView.isHidden = true + lockFallbackGlyphView.isUserInteractionEnabled = false + lockAnimationContainer.addSubview(lockFallbackGlyphView) } private func setupArrow() { - // Upward chevron arrow below the lock - let arrowPath = UIBezierPath() - let centerX = panelWidth / 2 - let arrowY: CGFloat = 30 - arrowPath.move(to: CGPoint(x: centerX - 5, y: arrowY + 5)) - arrowPath.addLine(to: CGPoint(x: centerX, y: arrowY)) - arrowPath.addLine(to: CGPoint(x: centerX + 5, y: arrowY + 5)) - - arrowLayer.path = arrowPath.cgPath - arrowLayer.strokeColor = UIColor.white.withAlphaComponent(0.6).cgColor - arrowLayer.fillColor = UIColor.clear.cgColor - arrowLayer.lineWidth = 1.5 - arrowLayer.lineCap = .round - arrowLayer.lineJoin = .round - layer.addSublayer(arrowLayer) + lockArrowView.image = VoiceRecordingAssets.image(.videoRecordArrow, templated: true) + lockArrowView.contentMode = .center + lockArrowView.isUserInteractionEnabled = false + addSubview(lockArrowView) } private func setupStopButton() { stopButton.isHidden = true stopButton.alpha = 0 - stopButton.backgroundColor = UIColor(red: 1, green: 45/255.0, blue: 85/255.0, alpha: 1) - stopButton.tintColor = .white - stopButton.layer.cornerRadius = 14 - stopButton.clipsToBounds = true - let iconConfig = UIImage.SymbolConfiguration(pointSize: 12, weight: .bold) - stopButton.setImage(UIImage(systemName: "stop.fill", withConfiguration: iconConfig), for: .normal) - stopButton.addTarget(self, action: #selector(stopTapped), for: .touchUpInside) - stopButton.isAccessibilityElement = true + stopButton.clipsToBounds = false + stopButton.backgroundColor = .clear + stopButton.isUserInteractionEnabled = false + stopButton.accessibilityIdentifier = "voice.recording.stop" stopButton.accessibilityLabel = "Stop recording" stopButton.accessibilityHint = "Stops voice recording and opens preview." + stopButton.addTarget(self, action: #selector(stopTapped), for: .touchUpInside) + + stopGlassView.isUserInteractionEnabled = false + stopGlassView.fixedCornerRadius = 20 + stopButton.addSubview(stopGlassView) + + stopBorderView.isUserInteractionEnabled = false + stopBorderView.backgroundColor = .clear + stopBorderView.layer.cornerCurve = .continuous + stopBorderView.layer.borderWidth = 1.0 / UIScreen.main.scale + stopBorderView.isHidden = true + stopButton.addSubview(stopBorderView) + + stopGlyphView.isUserInteractionEnabled = false + stopGlyphView.contentMode = .center + stopGlyphView.image = VoiceRecordingAssets.image(.pause, templated: true) + ?? Self.makeStopGlyphFallback() + stopButton.addSubview(stopGlyphView) + addSubview(stopButton) } + override func layoutSubviews() { + super.layoutSubviews() + updatePanelGeometry() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else { return } + updateAppearance() + } + + private func updatePanelGeometry() { + let clampedLockness = max(0, min(1, currentLockness)) + let panelHeight: CGFloat + let panelY: CGFloat + + switch visualState { + case .lock: + panelHeight = panelFullHeight - (panelFullHeight - panelLockedHeight) * clampedLockness + panelY = 40.0 * clampedLockness + case .stop: + panelHeight = panelLockedHeight + panelY = 32.0 + } + + let panelFrame = CGRect(x: 0, y: panelY, width: panelWidth, height: panelHeight) + panelGlassView.frame = panelFrame + panelGlassView.fixedCornerRadius = panelHeight / 2.0 + panelGlassView.applyCornerRadius() + + panelBorderView.frame = panelFrame + panelBorderView.layer.cornerRadius = panelHeight / 2.0 + + lockAnimationContainer.frame = CGRect(x: 0, y: 6.0, width: 40.0, height: 60.0) + idleLockView.frame = lockAnimationContainer.bounds + lockingView.frame = lockAnimationContainer.bounds + lockFallbackGlyphView.frame = CGRect(x: 8.0, y: 8.0, width: 24.0, height: 24.0) + + let arrowSize = lockArrowView.image?.size ?? CGSize(width: 18, height: 9) + lockArrowView.frame = CGRect( + x: floor((panelWidth - arrowSize.width) / 2.0), + y: 54.0, + width: arrowSize.width, + height: arrowSize.height + ) + + stopButton.frame = CGRect(x: 0, y: 32.0, width: 40.0, height: 40.0) + stopGlassView.frame = stopButton.bounds + stopGlassView.fixedCornerRadius = stopButton.bounds.height / 2.0 + stopGlassView.applyCornerRadius() + + stopBorderView.frame = stopButton.bounds + stopBorderView.layer.cornerRadius = stopButton.bounds.height / 2.0 + stopGlyphView.frame = stopButton.bounds + } + // MARK: - Present /// Position above anchor (mic button) and animate in with spring. func present(anchorCenter: CGPoint, in parent: UIView) { frame = CGRect( - x: floor(anchorCenter.x - panelWidth / 2), - y: floor(anchorCenter.y - verticalOffset - panelFullHeight / 2), + x: floor(anchorCenter.x - panelWidth / 2.0), + y: floor(anchorCenter.y - verticalOffset - panelFullHeight / 2.0), width: panelWidth, height: panelFullHeight ) - backgroundView.frame = bounds - stopButton.frame = CGRect(x: floor((panelWidth - 28) / 2), y: panelFullHeight - 34, width: 28, height: 28) + + currentLockness = 0 + visualState = .lock + onStopTap = nil + + lockAnimationContainer.transform = .identity + lockAnimationContainer.alpha = 1 + lockAnimationContainer.isHidden = false + lockAnimationContainer.isUserInteractionEnabled = false + + lockArrowView.transform = .identity + lockArrowView.alpha = 1 + lockArrowView.isHidden = false + + stopButton.isHidden = true + stopButton.alpha = 0 + stopButton.transform = .identity + stopButton.isUserInteractionEnabled = false + + lockingView.isHidden = true + lockingView.currentProgress = 0 + lockFallbackGlyphView.isHidden = true + if idleLockView.animation != nil { + idleLockView.isHidden = false + if !idleLockView.isAnimationPlaying { + idleLockView.play() + } + } else { + idleLockView.isHidden = true + lockFallbackGlyphView.isHidden = false + } + + updatePanelGeometry() + updateAppearance() parent.addSubview(self) - // Start offscreen below transform = CGAffineTransform(translationX: 0, y: 100) alpha = 0 - UIView.animate( - withDuration: 0.5, delay: 0, + withDuration: 0.5, + delay: 0, usingSpringWithDamping: 0.55, - initialSpringVelocity: 0, options: [] + initialSpringVelocity: 0, + options: [.beginFromCurrentState] ) { self.transform = .identity self.alpha = 1 @@ -161,51 +275,89 @@ final class RecordingLockView: UIView { // MARK: - Lockness Update /// Update lock progress (0 = idle, 1 = locked). - /// Telegram: arrow alpha = max(0, 1 - lockness * 1.6) func updateLockness(_ lockness: CGFloat) { - CATransaction.begin() - CATransaction.setDisableActions(true) - arrowLayer.opacity = Float(max(0, 1 - lockness * 1.6)) - CATransaction.commit() + guard visualState == .lock else { return } - // Lock icon shifts up slightly - let yOffset = -16 * lockness - CATransaction.begin() - CATransaction.setDisableActions(true) - lockIcon.transform = CATransform3DMakeTranslation(0, yOffset, 0) - CATransaction.commit() - } + currentLockness = max(0, min(1, lockness)) + updatePanelGeometry() - // MARK: - Animate Lock Complete + if currentLockness > 0 { + if idleLockView.isAnimationPlaying { + idleLockView.stop() + } + idleLockView.isHidden = true - /// Shrink and dismiss the lock panel after lock is committed. - /// Telegram: panel height 72→40, then slides down off-screen. - func animateLockComplete() { - UIView.animate(withDuration: 0.2) { - self.arrowLayer.opacity = 0 - self.lockIcon.transform = CATransform3DMakeTranslation(0, -16, 0) + if lockingView.animation != nil { + lockingView.isHidden = false + lockingView.currentProgress = currentLockness + lockFallbackGlyphView.isHidden = true + } else { + lockingView.isHidden = true + lockFallbackGlyphView.isHidden = false + lockFallbackGlyphView.alpha = 0.85 + 0.15 * currentLockness + lockFallbackGlyphView.transform = CGAffineTransform( + scaleX: 0.94 + 0.06 * currentLockness, + y: 0.94 + 0.06 * currentLockness + ) + } + } else { + lockingView.isHidden = true + lockFallbackGlyphView.transform = .identity + + if idleLockView.animation != nil { + idleLockView.isHidden = false + lockFallbackGlyphView.isHidden = true + if !idleLockView.isAnimationPlaying { + idleLockView.play() + } + } else { + idleLockView.isHidden = true + lockFallbackGlyphView.isHidden = false + } } - // Slide down and fade after 0.45s - UIView.animate(withDuration: 0.2, delay: 0.45, options: []) { - self.transform = CGAffineTransform(translationX: 0, y: 120) - } completion: { _ in - self.alpha = 0 - self.removeFromSuperview() - } + lockAnimationContainer.transform = CGAffineTransform(translationX: 0, y: -11.0 * currentLockness) + lockArrowView.transform = CGAffineTransform(translationX: 0, y: -39.0 * currentLockness) + lockArrowView.alpha = max(0, 1 - currentLockness * 1.6) } func showStopButton(onTap: @escaping () -> Void) { onStopTap = onTap - stopButton.isHidden = false - stopButton.transform = CGAffineTransform(scaleX: 0.5, y: 0.5) + visualState = .stop + currentLockness = 1.0 - UIView.animate(withDuration: 0.2) { - self.arrowLayer.opacity = 0 - self.lockIcon.transform = CATransform3DMakeTranslation(0, -16, 0) + if idleLockView.isAnimationPlaying { + idleLockView.stop() } + idleLockView.isHidden = true + lockingView.isHidden = true + lockFallbackGlyphView.isHidden = true - UIView.animate(withDuration: 0.2, delay: 0.02, options: [.curveEaseOut]) { + lockAnimationContainer.layer.removeAllAnimations() + lockAnimationContainer.alpha = 0 + lockAnimationContainer.isHidden = true + lockAnimationContainer.isUserInteractionEnabled = false + + lockArrowView.layer.removeAllAnimations() + lockArrowView.alpha = 0 + lockArrowView.isHidden = true + lockArrowView.isUserInteractionEnabled = false + + updatePanelGeometry() + updateAppearance() + + // Hide panel glass so it doesn't stack under stopGlassView (double glass fix) + panelGlassView.alpha = 0 + panelBorderView.alpha = 0 + + stopButton.isHidden = false + stopButton.isUserInteractionEnabled = true + stopButton.transform = CGAffineTransform(scaleX: 0.5, y: 0.5) + stopButton.alpha = 0 + stopButton.layer.zPosition = 100 + bringSubviewToFront(stopButton) + + UIView.animate(withDuration: 0.25, delay: 0.56, options: [.curveEaseOut]) { self.stopButton.alpha = 1 self.stopButton.transform = .identity } @@ -214,6 +366,11 @@ final class RecordingLockView: UIView { // MARK: - Dismiss func dismiss() { + visualState = .lock + if idleLockView.isAnimationPlaying { + idleLockView.stop() + } + UIView.animate(withDuration: 0.18) { self.alpha = 0 self.transform = CGAffineTransform(scaleX: 0.5, y: 0.5) @@ -222,7 +379,77 @@ final class RecordingLockView: UIView { } } + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + if isShowingStopButton { + return stopButton.frame.insetBy(dx: -10, dy: -10).contains(point) + } + return bounds.insetBy(dx: -6, dy: -6).contains(point) + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard self.point(inside: point, with: event), !isHidden, alpha > 0.01 else { return nil } + if isShowingStopButton { + let stopPoint = convert(point, to: stopButton) + if stopButton.point(inside: stopPoint, with: event) { + return stopButton + } + return nil + } + return super.hitTest(point, with: event) + } + + private func updateAppearance() { + let isDark = traitCollection.userInterfaceStyle == .dark + let iconColor: UIColor + let borderColor: UIColor + + if usesComponentVisuals { + iconColor = isDark ? UIColor(white: 0.95, alpha: 0.92) : UIColor(white: 0.05, alpha: 0.92) + borderColor = isDark ? UIColor(white: 1.0, alpha: 0.22) : UIColor(white: 0.0, alpha: 0.14) + } else { + iconColor = UIColor(white: 0.58, alpha: 1.0) + borderColor = UIColor(white: 0.7, alpha: 0.55) + } + + panelBorderView.layer.borderColor = borderColor.cgColor + panelBorderView.isHidden = usesComponentVisuals + panelBorderView.alpha = visualState == .lock ? 1.0 : 0.0 + + stopBorderView.layer.borderColor = borderColor.cgColor + stopGlyphView.tintColor = iconColor + lockArrowView.tintColor = iconColor + lockFallbackGlyphView.tintColor = iconColor + } + + // MARK: - Stop Action + @objc private func stopTapped() { + stopButton.isUserInteractionEnabled = false + UIView.animate(withDuration: 0.2) { + self.stopButton.alpha = 0 + } onStopTap?() } + + // MARK: - Images + + private static func makeStopGlyphFallback() -> UIImage? { + let size = CGSize(width: 30, height: 30) + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { ctx in + ctx.cgContext.setFillColor(UIColor.black.cgColor) + + let leftBar = UIBezierPath( + roundedRect: CGRect(x: 7.5, y: 8.0, width: 5.0, height: 14.0), + cornerRadius: 1.0 + ) + leftBar.fill() + + let rightBar = UIBezierPath( + roundedRect: CGRect(x: 17.5, y: 8.0, width: 5.0, height: 14.0), + cornerRadius: 1.0 + ) + rightBar.fill() + } + } } diff --git a/Rosetta/Features/Chats/ChatDetail/RecordingMicButton.swift b/Rosetta/Features/Chats/ChatDetail/RecordingMicButton.swift index 7f7f88c..a4a5d73 100644 --- a/Rosetta/Features/Chats/ChatDetail/RecordingMicButton.swift +++ b/Rosetta/Features/Chats/ChatDetail/RecordingMicButton.swift @@ -58,13 +58,13 @@ final class RecordingMicButton: UIControl { // MARK: - Gesture Thresholds (Telegram parity) - private let holdThreshold: TimeInterval = 0.19 - private let cancelDistanceThreshold: CGFloat = -150 - private let cancelHapticThreshold: CGFloat = -100 - private let lockDistanceThreshold: CGFloat = -110 - private let lockHapticThreshold: CGFloat = -60 - private let velocityGate: CGFloat = -400 - private let preHoldCancelDistance: CGFloat = 10 + private let holdThreshold: TimeInterval = VoiceRecordingParityConstants.holdThreshold + private let cancelDistanceThreshold: CGFloat = VoiceRecordingParityConstants.cancelDistanceThreshold + private let cancelHapticThreshold: CGFloat = VoiceRecordingParityConstants.cancelHapticThreshold + private let lockDistanceThreshold: CGFloat = VoiceRecordingParityConstants.lockDistanceThreshold + private let lockHapticThreshold: CGFloat = VoiceRecordingParityConstants.lockHapticThreshold + private let velocityGate: CGFloat = VoiceRecordingParityConstants.velocityGate + private let preHoldCancelDistance: CGFloat = VoiceRecordingParityConstants.preHoldCancelDistance // MARK: - Tracking State @@ -191,28 +191,32 @@ final class RecordingMicButton: UIControl { } if recordingState == .recording { - // Telegram velocity gate: fast flick left/up commits immediately. - if velocityX < velocityGate { - commitCancel() - return - } - if velocityY < velocityGate { - commitLock() - return - } - // Fallback to distance thresholds on release. if let touch { let location = touch.location(in: window) - let distanceX = location.x - touchStartLocation.x - let distanceY = location.y - touchStartLocation.y - if distanceX < cancelDistanceThreshold { + var distanceX = min(0, location.x - touchStartLocation.x) + var distanceY = min(0, location.y - touchStartLocation.y) + + // Telegram parity: keep only dominant direction on release. + (distanceX, distanceY) = VoiceRecordingParityMath.dominantAxisDistances( + distanceX: distanceX, + distanceY: distanceY + ) + + switch VoiceRecordingParityMath.releaseDecision( + velocityX: velocityX, + velocityY: velocityY, + distanceX: distanceX, + distanceY: distanceY + ) { + case .cancel: commitCancel() return - } - if distanceY < lockDistanceThreshold { + case .lock: commitLock() return + case .finish: + break } } @@ -223,8 +227,12 @@ final class RecordingMicButton: UIControl { override func cancelTracking(with event: UIEvent?) { if recordingState == .recording { - // Touch cancelled (e.g. system gesture) → lock instead of cancel - commitLock() + // Telegram parity: delayed lock after cancelTracking. + targetLockTranslation = 0 + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + guard let self, self.recordingState == .recording else { return } + self.commitLock() + } } else { cancelHoldTimer() recordingState = .idle @@ -234,7 +242,7 @@ final class RecordingMicButton: UIControl { } override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { - bounds.insetBy(dx: -10, dy: 0).contains(point) + bounds.insetBy(dx: VoiceRecordingParityConstants.micHitInsetX, dy: 0).contains(point) } // MARK: - State Transitions @@ -286,11 +294,18 @@ final class RecordingMicButton: UIControl { currentLockTranslation = 0 } +#if DEBUG + func debugSetRecordingState(_ state: VoiceRecordingState) { + recordingState = state + } +#endif + // MARK: - Display Link private func startDisplayLink() { guard displayLink == nil else { return } let link = CADisplayLink(target: self, selector: #selector(displayLinkUpdate)) + link.preferredFrameRateRange = CAFrameRateRange(minimum: 24, maximum: 30, preferred: 30) link.add(to: .main, forMode: .common) displayLink = link } diff --git a/Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift b/Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift index 47298d6..b32286a 100644 --- a/Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift +++ b/Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift @@ -1,4 +1,5 @@ import AVFAudio +import Lottie import QuartzCore import UIKit @@ -23,6 +24,11 @@ final class RecordingPreviewPanel: UIView { case trimRight } + private enum PlayPauseVisualState { + case play + case pause + } + weak var delegate: RecordingPreviewPanelDelegate? // MARK: - Subviews @@ -30,6 +36,7 @@ final class RecordingPreviewPanel: UIView { private let glassBackground = TelegramGlassUIView(frame: .zero) private let deleteButton = UIButton(type: .system) private let playButton = UIButton(type: .system) + private let playPauseAnimationView = LottieAnimationView() private let waveformContainer = UIView() private let waveformView = WaveformView() private let leftTrimMask = UIView() @@ -45,6 +52,7 @@ final class RecordingPreviewPanel: UIView { private var audioPlayer: AVAudioPlayer? private var displayLink: CADisplayLink? private var isPlaying = false + private var playPauseState: PlayPauseVisualState = .pause private let fileURL: URL private let duration: TimeInterval private let waveformSamples: [Float] @@ -56,6 +64,24 @@ final class RecordingPreviewPanel: UIView { private var minTrimDuration: TimeInterval = 1 private var activePanMode: PanMode? + private var panelControlColor: UIColor { + UIColor { traits in + traits.userInterfaceStyle == .dark ? UIColor.white : UIColor.black + } + } + + private var panelControlAccentColor: UIColor { + UIColor(red: 0, green: 136 / 255.0, blue: 1.0, alpha: 1.0) + } + + private var panelSecondaryTextColor: UIColor { + panelControlColor.withAlphaComponent(0.7) + } + + private var panelWaveformBackgroundColor: UIColor { + panelControlColor.withAlphaComponent(0.4) + } + var selectedTrimRange: ClosedRange { trimStart...trimEnd } @@ -71,6 +97,7 @@ final class RecordingPreviewPanel: UIView { clipsToBounds = true layer.cornerRadius = 21 layer.cornerCurve = .continuous + accessibilityIdentifier = "voice.preview.panel" setupSubviews() } @@ -84,20 +111,28 @@ final class RecordingPreviewPanel: UIView { glassBackground.isUserInteractionEnabled = false addSubview(glassBackground) - let trashConfig = UIImage.SymbolConfiguration(pointSize: 18, weight: .medium) - deleteButton.setImage(UIImage(systemName: "trash", withConfiguration: trashConfig), for: .normal) + deleteButton.setImage(VoiceRecordingAssets.image(.delete, templated: true), for: .normal) deleteButton.tintColor = UIColor(red: 1, green: 45/255.0, blue: 85/255.0, alpha: 1) deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) deleteButton.isAccessibilityElement = true deleteButton.accessibilityLabel = "Delete recording" deleteButton.accessibilityHint = "Deletes the current voice draft." + deleteButton.accessibilityIdentifier = "voice.preview.delete" addSubview(deleteButton) - configurePlayButton(playing: false) + playPauseAnimationView.backgroundBehavior = .pauseAndRestore + playPauseAnimationView.contentMode = .scaleAspectFit + playPauseAnimationView.isUserInteractionEnabled = false + playButton.addSubview(playPauseAnimationView) + if let animation = LottieAnimation.named(VoiceRecordingLottieAsset.playPause.rawValue) { + playPauseAnimationView.animation = animation + } + configurePlayButton(playing: false, animated: false) playButton.addTarget(self, action: #selector(playTapped), for: .touchUpInside) playButton.isAccessibilityElement = true playButton.accessibilityLabel = "Play recording" playButton.accessibilityHint = "Plays or pauses voice preview." + playButton.accessibilityIdentifier = "voice.preview.playPause" addSubview(playButton) waveformContainer.clipsToBounds = true @@ -126,29 +161,35 @@ final class RecordingPreviewPanel: UIView { waveformContainer.isAccessibilityElement = true waveformContainer.accessibilityLabel = "Waveform trim area" waveformContainer.accessibilityHint = "Drag to scrub, or drag edges to trim." + waveformContainer.accessibilityIdentifier = "voice.preview.waveform" durationLabel.font = .monospacedDigitSystemFont(ofSize: 13, weight: .semibold) - durationLabel.textColor = .white.withAlphaComponent(0.72) + durationLabel.textColor = panelSecondaryTextColor durationLabel.textAlignment = .right addSubview(durationLabel) - let recordMoreConfig = UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold) - recordMoreButton.setImage(UIImage(systemName: "plus.circle", withConfiguration: recordMoreConfig), for: .normal) - recordMoreButton.tintColor = .white.withAlphaComponent(0.85) + recordMoreButton.setImage(VoiceRecordingAssets.image(.iconMicrophone, templated: true), for: .normal) + recordMoreButton.tintColor = panelControlColor.withAlphaComponent(0.85) recordMoreButton.addTarget(self, action: #selector(recordMoreTapped), for: .touchUpInside) recordMoreButton.isAccessibilityElement = true recordMoreButton.accessibilityLabel = "Record more" recordMoreButton.accessibilityHint = "Resume recording and append more audio." + recordMoreButton.accessibilityIdentifier = "voice.preview.recordMore" addSubview(recordMoreButton) - let sendConfig = UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold) - sendButton.setImage(UIImage(systemName: "arrow.up.circle.fill", withConfiguration: sendConfig), for: .normal) - sendButton.tintColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1) + sendButton.setImage(VoiceRecordingAssets.image(.send, templated: true), for: .normal) + sendButton.backgroundColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1) + sendButton.layer.cornerRadius = 18 + sendButton.clipsToBounds = true + sendButton.tintColor = .white sendButton.addTarget(self, action: #selector(sendTapped), for: .touchUpInside) sendButton.isAccessibilityElement = true sendButton.accessibilityLabel = "Send recording" sendButton.accessibilityHint = "Sends current trimmed voice message." + sendButton.accessibilityIdentifier = "voice.preview.send" addSubview(sendButton) + + updateThemeColors() } // MARK: - Layout @@ -157,17 +198,25 @@ final class RecordingPreviewPanel: UIView { super.layoutSubviews() let h = bounds.height let w = bounds.width + let trailingInset: CGFloat = 4 + let controlGap: CGFloat = 4 glassBackground.frame = bounds glassBackground.applyCornerRadius() deleteButton.frame = CGRect(x: 4, y: (h - 40) / 2, width: 40, height: 40) playButton.frame = CGRect(x: 44, y: (h - 30) / 2, width: 30, height: 30) + playPauseAnimationView.frame = CGRect(x: 4, y: 4, width: 22, height: 22) - sendButton.frame = CGRect(x: w - 40, y: (h - 36) / 2, width: 36, height: 36) - recordMoreButton.frame = CGRect(x: sendButton.frame.minX - 34, y: (h - 30) / 2, width: 30, height: 30) + sendButton.frame = CGRect(x: w - trailingInset - 36, y: (h - 36) / 2, width: 36, height: 36) + recordMoreButton.frame = CGRect( + x: sendButton.frame.minX - controlGap - 30, + y: (h - 30) / 2, + width: 30, + height: 30 + ) - let durationW: CGFloat = 44 + let durationW: CGFloat = 48 durationLabel.frame = CGRect( x: recordMoreButton.frame.minX - durationW - 6, y: (h - 20) / 2, @@ -180,12 +229,21 @@ final class RecordingPreviewPanel: UIView { waveformContainer.frame = CGRect(x: waveX, y: 4, width: max(0, waveW), height: h - 8) waveformView.frame = waveformContainer.bounds - minTrimDuration = max(1.0, 56.0 * duration / max(waveformContainer.bounds.width, 1)) + minTrimDuration = VoiceRecordingParityConstants.minTrimDuration( + duration: duration, + waveformWidth: waveformContainer.bounds.width + ) trimEnd = max(trimEnd, min(duration, trimStart + minTrimDuration)) updateTrimVisuals() updateDurationLabel(isPlaying ? remainingFromPlayer() : (trimEnd - trimStart)) } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else { return } + updateThemeColors() + } + // MARK: - Play/Pause @objc private func playTapped() { @@ -207,14 +265,14 @@ final class RecordingPreviewPanel: UIView { } player.play() isPlaying = true - configurePlayButton(playing: true) + configurePlayButton(playing: true, animated: true) startDisplayLink() } private func pausePlayback() { audioPlayer?.pause() isPlaying = false - configurePlayButton(playing: false) + configurePlayButton(playing: false, animated: true) stopDisplayLink() } @@ -227,16 +285,44 @@ final class RecordingPreviewPanel: UIView { waveformView.progress = 0 } isPlaying = false - configurePlayButton(playing: false) + configurePlayButton(playing: false, animated: false) updateDurationLabel(trimEnd - trimStart) stopDisplayLink() } - private func configurePlayButton(playing: Bool) { + private func configurePlayButton(playing: Bool, animated: Bool) { + let targetState: PlayPauseVisualState = playing ? .pause : .play + guard playPauseState != targetState else { return } + let previous = playPauseState + playPauseState = targetState + + if playPauseAnimationView.animation != nil { + playButton.setImage(nil, for: .normal) + switch (previous, targetState) { + case (.play, .pause): + if animated { + playPauseAnimationView.play(fromFrame: 0, toFrame: 41, loopMode: .playOnce) + } else { + playPauseAnimationView.currentFrame = 41 + } + case (.pause, .play): + if animated { + playPauseAnimationView.play(fromFrame: 41, toFrame: 83, loopMode: .playOnce) + } else { + playPauseAnimationView.currentFrame = 0 + } + case (.play, .play): + playPauseAnimationView.currentFrame = 0 + case (.pause, .pause): + playPauseAnimationView.currentFrame = 41 + } + return + } + let config = UIImage.SymbolConfiguration(pointSize: 18, weight: .semibold) - let name = playing ? "pause.fill" : "play.fill" - playButton.setImage(UIImage(systemName: name, withConfiguration: config), for: .normal) - playButton.tintColor = .white + let fallbackName = playing ? "pause.fill" : "play.fill" + playButton.setImage(UIImage(systemName: fallbackName, withConfiguration: config), for: .normal) + playButton.tintColor = panelSecondaryTextColor } // MARK: - Display Link @@ -244,6 +330,7 @@ final class RecordingPreviewPanel: UIView { private func startDisplayLink() { guard displayLink == nil else { return } let link = CADisplayLink(target: self, selector: #selector(displayLinkTick)) + link.preferredFrameRateRange = CAFrameRateRange(minimum: 24, maximum: 30, preferred: 30) link.add(to: .main, forMode: .common) displayLink = link } @@ -359,6 +446,30 @@ final class RecordingPreviewPanel: UIView { durationLabel.text = String(format: "%d:%02d", minutes, seconds) } + private func updateThemeColors() { + durationLabel.textColor = panelSecondaryTextColor + recordMoreButton.tintColor = panelControlColor.withAlphaComponent(0.85) + waveformView.backgroundColor_ = panelWaveformBackgroundColor + waveformView.foregroundColor_ = panelControlAccentColor + waveformView.setNeedsDisplay() + applyPlayPauseTintColor(panelSecondaryTextColor) + if playPauseAnimationView.animation == nil { + playButton.tintColor = panelSecondaryTextColor + } + } + + private func applyPlayPauseTintColor(_ color: UIColor) { + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + color.getRed(&r, green: &g, blue: &b, alpha: &a) + playPauseAnimationView.setValueProvider( + ColorValueProvider(LottieColor(r: Double(r), g: Double(g), b: Double(b), a: Double(a))), + keypath: AnimationKeypath(keypath: "**.Color") + ) + } + // MARK: - Actions @objc private func deleteTapped() { diff --git a/Rosetta/Features/Chats/ChatDetail/VoiceRecordingAssets.swift b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingAssets.swift new file mode 100644 index 0000000..37d3368 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingAssets.swift @@ -0,0 +1,37 @@ +import UIKit + +enum VoiceRecordingAsset: String { + case iconMicrophone = "VoiceRecordingIconMicrophone" + case iconVideo = "VoiceRecordingIconVideo" + case cancelArrow = "VoiceRecordingCancelArrow" + case inputMicOverlay = "VoiceRecordingInputMicOverlay" + case recordSendIcon = "VoiceRecordingRecordSendIcon" + case videoRecordArrow = "VoiceRecordingVideoRecordArrow" + case pause = "VoiceRecordingPause" + case switchCamera = "VoiceRecordingSwitchCamera" + case viewOnce = "VoiceRecordingViewOnce" + case viewOnceEnabled = "VoiceRecordingViewOnceEnabled" + case delete = "VoiceRecordingDelete" + case send = "VoiceRecordingSend" +} + +enum VoiceRecordingLottieAsset: String { + case binRed = "voice_bin_red" + case binBlue = "voice_bin_blue" + case lockWait = "voice_lock_wait" + case lock = "voice_lock" + case lockPause = "voice_lock_pause" + case playPause = "voice_anim_playpause" + case micToVideo = "voice_anim_mic_to_video" + case videoToMic = "voice_anim_video_to_mic" +} + +enum VoiceRecordingAssets { + static func image(_ asset: VoiceRecordingAsset, templated: Bool = false) -> UIImage? { + guard let image = UIImage(named: asset.rawValue) else { + assertionFailure("Missing voice recording asset: \(asset.rawValue)") + return nil + } + return templated ? image.withRenderingMode(.alwaysTemplate) : image + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift index 4d63838..08babec 100644 --- a/Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift +++ b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift @@ -11,7 +11,7 @@ import UIKit /// Z-order (back→front): outerCircle → innerCircle → micIcon /// Inner circle: 110pt, #0088FF, opaque /// Outer circle: 160pt, #0088FF alpha 0.2, scales with audio -/// Mic icon: white SVG, 25x34pt, top-most layer +/// Mic icon: Telegram `InputMicRecordingOverlay`, top-most layer final class VoiceRecordingOverlay { // Telegram exact (lines 11-13 of TGModernConversationInputMicButton.m) @@ -27,7 +27,7 @@ final class VoiceRecordingOverlay { private let containerView = UIView() private let outerCircle = UIView() private let innerCircle = UIView() - private let micIconLayer = CAShapeLayer() + private let iconView = UIImageView() // MARK: - Display Link @@ -36,6 +36,8 @@ final class VoiceRecordingOverlay { private var animationStartTime: Double = 0 private var currentLevel: CGFloat = 0 private var inputLevel: CGFloat = 0 + private var dragDistanceX: CGFloat = 0 + private var dragDistanceY: CGFloat = 0 private var isLocked = false private var onTapStop: (() -> Void)? @@ -55,13 +57,12 @@ final class VoiceRecordingOverlay { innerCircle.bounds = CGRect(origin: .zero, size: CGSize(width: innerDiameter, height: innerDiameter)) innerCircle.layer.cornerRadius = innerDiameter / 2 - // Mic icon SVG - configureMicIcon() + configureIcon() // Z-order: outer (back) → inner → icon (front) containerView.addSubview(outerCircle) containerView.addSubview(innerCircle) - containerView.layer.addSublayer(micIconLayer) + containerView.addSubview(iconView) } deinit { @@ -69,18 +70,11 @@ final class VoiceRecordingOverlay { containerView.removeFromSuperview() } - private func configureMicIcon() { - let viewBox = CGSize(width: 17.168, height: 23.555) - let targetSize = CGSize(width: 25, height: 34) - - var parser = SVGPathParser(pathData: TelegramIconPath.microphone) - let cgPath = parser.parse() - - let sx = targetSize.width / viewBox.width - let sy = targetSize.height / viewBox.height - micIconLayer.path = cgPath.copy(using: [CGAffineTransform(scaleX: sx, y: sy)]) - micIconLayer.fillColor = UIColor.white.cgColor - micIconLayer.bounds = CGRect(origin: .zero, size: targetSize) + private func configureIcon() { + iconView.image = VoiceRecordingAssets.image(.inputMicOverlay, templated: true) + iconView.tintColor = .white + iconView.contentMode = .center + iconView.isUserInteractionEnabled = false } // MARK: - Present (Telegram exact: spring damping 0.55, duration 0.5s) @@ -90,7 +84,7 @@ final class VoiceRecordingOverlay { // Telegram: centerOffset = (0, -1 + screenPixel) var center = superview.convert(anchorView.center, to: window) - center.y -= 1.0 + center.y += (-1.0 + (1.0 / UIScreen.main.scale)) containerView.bounds = CGRect(origin: .zero, size: CGSize(width: outerDiameter, height: outerDiameter)) containerView.center = center @@ -100,10 +94,13 @@ final class VoiceRecordingOverlay { let mid = CGPoint(x: outerDiameter / 2, y: outerDiameter / 2) outerCircle.center = mid innerCircle.center = mid - CATransaction.begin() - CATransaction.setDisableActions(true) - micIconLayer.position = mid - CATransaction.commit() + let iconSize = iconView.image?.size ?? CGSize(width: 30, height: 30) + iconView.frame = CGRect( + x: floor(mid.x - iconSize.width / 2.0), + y: floor(mid.y - iconSize.height / 2.0), + width: iconSize.width, + height: iconSize.height + ) window.addSubview(containerView) @@ -112,37 +109,34 @@ final class VoiceRecordingOverlay { innerCircle.alpha = 0.2 outerCircle.transform = CGAffineTransform(scaleX: 0.2, y: 0.2) outerCircle.alpha = 0.2 - micIconLayer.opacity = 0.2 + iconView.alpha = 0.2 + iconView.transform = CGAffineTransform(scaleX: 0.2, y: 0.2) // Alpha fade: 0.15s (Telegram exact) UIView.animate(withDuration: 0.15) { self.innerCircle.alpha = 1 self.outerCircle.alpha = 1 + self.iconView.alpha = 1 } - let iconFade = CABasicAnimation(keyPath: "opacity") - iconFade.fromValue = 0.2 - iconFade.toValue = 1.0 - iconFade.duration = 0.15 - micIconLayer.opacity = 1.0 - micIconLayer.add(iconFade, forKey: "fadeIn") // Spring scale: damping 0.55, duration 0.5s (Telegram exact) // Inner → 1.0 UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.55, initialSpringVelocity: 0, options: .beginFromCurrentState) { self.innerCircle.transform = .identity + self.iconView.transform = .identity } // Outer → outerMinScale (0.6875) UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.55, initialSpringVelocity: 0, options: .beginFromCurrentState) { - self.outerCircle.transform = CGAffineTransform(scaleX: self.outerMinScale, y: self.outerMinScale) + self.applyCurrentTransforms() } animationStartTime = CACurrentMediaTime() startDisplayLink() } - // MARK: - Lock Transition (mic → stop icon, tappable) + // MARK: - Lock Transition (mic overlay → send icon, tappable) - /// Transition to locked state: mic icon → stop icon, overlay becomes tappable. + /// Transition to locked state: icon morphs to Telegram `RecordSendIcon`. /// Telegram: TGModernConversationInputMicButton.m line 616-693 func transitionToLocked(onTapStop: @escaping () -> Void) { isLocked = true @@ -162,74 +156,40 @@ final class VoiceRecordingOverlay { self.outerCircle.transform = CGAffineTransform( scaleX: self.outerMinScale, y: self.outerMinScale ) + self.iconView.transform = .identity } - // Transition icon: mic → stop (two vertical bars) - transitionToStopIcon() + dragDistanceX = 0 + dragDistanceY = 0 + + // Transition icon: mic overlay → send icon. + transitionToSendIcon() } - /// Animate mic icon → stop icon (Telegram: snapshot + cross-fade, 0.3s) - private func transitionToStopIcon() { - // Create stop icon path (two parallel vertical bars, Telegram exact) - let stopPath = UIBezierPath() - let barW: CGFloat = 4 - let barH: CGFloat = 16 - let gap: CGFloat = 6 - let totalW = barW * 2 + gap - let originX = -totalW / 2 - let originY = -barH / 2 - // Left bar - stopPath.append(UIBezierPath( - roundedRect: CGRect(x: originX, y: originY, width: barW, height: barH), - cornerRadius: 1 - )) - // Right bar - stopPath.append(UIBezierPath( - roundedRect: CGRect(x: originX + barW + gap, y: originY, width: barW, height: barH), - cornerRadius: 1 - )) + /// Telegram-style icon transition using snapshot shrink + new icon grow. + private func transitionToSendIcon() { + guard let sendImage = VoiceRecordingAssets.image(.recordSendIcon, templated: true) else { + return + } + let snapshot = iconView.snapshotView(afterScreenUpdates: false) + snapshot?.frame = iconView.frame + if let snapshot { + containerView.addSubview(snapshot) + } - // Animate: old icon scales down, new icon scales up - let newIconLayer = CAShapeLayer() - newIconLayer.path = stopPath.cgPath - newIconLayer.fillColor = UIColor.white.cgColor - let mid = CGPoint(x: outerDiameter / 2, y: outerDiameter / 2) - newIconLayer.position = mid - newIconLayer.transform = CATransform3DMakeScale(0.3, 0.3, 1) - newIconLayer.opacity = 0 - containerView.layer.addSublayer(newIconLayer) + iconView.image = sendImage + iconView.tintColor = .white + iconView.transform = CGAffineTransform(scaleX: 0.3, y: 0.3) + iconView.alpha = 0 - // Old mic icon scales to 0 - let shrink = CABasicAnimation(keyPath: "transform.scale") - shrink.toValue = 0.001 - shrink.duration = 0.3 - shrink.fillMode = .forwards - shrink.isRemovedOnCompletion = false - micIconLayer.add(shrink, forKey: "shrink") - - let fadeOut = CABasicAnimation(keyPath: "opacity") - fadeOut.toValue = 0 - fadeOut.duration = 0.2 - fadeOut.fillMode = .forwards - fadeOut.isRemovedOnCompletion = false - micIconLayer.add(fadeOut, forKey: "fadeOutMic") - - // New stop icon grows in - let grow = CABasicAnimation(keyPath: "transform.scale") - grow.fromValue = 0.3 - grow.toValue = 1.0 - grow.duration = 0.3 - grow.fillMode = .forwards - grow.isRemovedOnCompletion = false - newIconLayer.add(grow, forKey: "grow") - - let fadeIn = CABasicAnimation(keyPath: "opacity") - fadeIn.fromValue = 0 - fadeIn.toValue = 1 - fadeIn.duration = 0.25 - fadeIn.fillMode = .forwards - fadeIn.isRemovedOnCompletion = false - newIconLayer.add(fadeIn, forKey: "fadeInStop") + UIView.animate(withDuration: 0.3, delay: 0, options: [.curveEaseInOut]) { + self.iconView.transform = .identity + self.iconView.alpha = 1 + snapshot?.transform = CGAffineTransform(scaleX: 0.001, y: 0.001) + snapshot?.alpha = 0 + } completion: { _ in + snapshot?.removeFromSuperview() + } } // MARK: - Dismiss (Telegram exact: 0.18s, scale→0.2, alpha→0) @@ -246,16 +206,10 @@ final class VoiceRecordingOverlay { self.innerCircle.alpha = 0 self.outerCircle.transform = CGAffineTransform(scaleX: 0.2, y: 0.2) self.outerCircle.alpha = 0 + self.iconView.transform = CGAffineTransform(scaleX: 0.2, y: 0.2) + self.iconView.alpha = 0 }) - let iconFade = CABasicAnimation(keyPath: "opacity") - iconFade.fromValue = 1.0 - iconFade.toValue = 0.0 - iconFade.duration = 0.18 - iconFade.fillMode = .forwards - iconFade.isRemovedOnCompletion = false - micIconLayer.add(iconFade, forKey: "fadeOut") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { container.removeFromSuperview() } @@ -271,17 +225,14 @@ final class VoiceRecordingOverlay { self.innerCircle.transform = CGAffineTransform(translationX: -80, y: 0) .scaledBy(x: 0.2, y: 0.2) self.innerCircle.alpha = 0 - self.outerCircle.transform = CGAffineTransform(scaleX: 0.2, y: 0.2) + self.outerCircle.transform = CGAffineTransform(translationX: -80, y: 0) + .scaledBy(x: 0.2, y: 0.2) self.outerCircle.alpha = 0 + self.iconView.transform = CGAffineTransform(translationX: -80, y: 0) + .scaledBy(x: 0.2, y: 0.2) + self.iconView.alpha = 0 }) - let iconFade = CABasicAnimation(keyPath: "opacity") - iconFade.toValue = 0.0 - iconFade.duration = 0.18 - iconFade.fillMode = .forwards - iconFade.isRemovedOnCompletion = false - micIconLayer.add(iconFade, forKey: "fadeOut") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { container.removeFromSuperview() } @@ -300,39 +251,9 @@ final class VoiceRecordingOverlay { /// Telegram exact from TGModernConversationInputMicButton.m func applyDragTransform(distanceX: CGFloat, distanceY: CGFloat) { guard CACurrentMediaTime() > animationStartTime else { return } - - // Telegram cancel-transform threshold: 8pt - guard abs(distanceX) > 8 || abs(distanceY) > 8 else { return } - - // Telegram line 763: normalize to 0..1 over 300pt range - let valueX = max(0, min(1, abs(distanceX) / 300)) - - // Telegram line 768: inner scale squeezes from 1.0 → 0.4 - let innerScale = max(0.4, min(1.0, 1.0 - valueX)) - - // Vertical translation (follows finger) - let translation = CGAffineTransform(translationX: 0, y: distanceY) - - // Telegram line 922-924: outer circle = translation + audio scale - let outerScale = outerMinScale + currentLevel * (1.0 - outerMinScale) - outerCircle.transform = translation.scaledBy(x: outerScale, y: outerScale) - - // Telegram line 931-932: inner circle = translation + cancel scale + horizontal offset - let innerTransform = translation - .scaledBy(x: innerScale, y: innerScale) - .translatedBy(x: distanceX, y: 0) - innerCircle.transform = innerTransform - - // Icon follows inner circle - CATransaction.begin() - CATransaction.setDisableActions(true) - let mid = CGPoint(x: outerDiameter / 2, y: outerDiameter / 2) - micIconLayer.position = CGPoint( - x: mid.x + distanceX * innerScale, - y: mid.y + distanceY - ) - micIconLayer.transform = CATransform3DMakeScale(innerScale, innerScale, 1) - CATransaction.commit() + dragDistanceX = min(0, distanceX) + dragDistanceY = min(0, distanceY) + applyCurrentTransforms() } // MARK: - Display Link (Telegram: displayLinkEvent, 0.8/0.2 smoothing) @@ -341,6 +262,7 @@ final class VoiceRecordingOverlay { guard displayLink == nil else { return } let target = DisplayLinkTarget { [weak self] in self?.tick() } let link = CADisplayLink(target: target, selector: #selector(DisplayLinkTarget.tick)) + link.preferredFrameRateRange = CAFrameRateRange(minimum: 24, maximum: 30, preferred: 30) link.add(to: .main, forMode: .common) displayLink = link displayLinkTarget = target @@ -359,9 +281,27 @@ final class VoiceRecordingOverlay { // Telegram exact: TGModernConversationInputMicButton.m line 916 (0.9/0.1) currentLevel = currentLevel * 0.9 + inputLevel * 0.1 - // Telegram exact: outerCircleMinScale + currentLevel * (1.0 - outerCircleMinScale) - let scale = outerMinScale + currentLevel * (1.0 - outerMinScale) - outerCircle.transform = CGAffineTransform(scaleX: scale, y: scale) + applyCurrentTransforms() + } + + private func applyCurrentTransforms() { + let valueX = max(0, min(1, abs(dragDistanceX) / 300)) + let innerScale = max(0.4, min(1.0, 1.0 - valueX)) + let translatedX = dragDistanceX * innerScale + + let translation = CGAffineTransform(translationX: translatedX, y: dragDistanceY) + let outerScale = outerMinScale + currentLevel * (1.0 - outerMinScale) + outerCircle.transform = translation.scaledBy(x: outerScale, y: outerScale) + + let innerTransform = translation.scaledBy(x: innerScale, y: innerScale) + innerCircle.transform = innerTransform + + let mid = CGPoint(x: outerDiameter / 2, y: outerDiameter / 2) + iconView.center = CGPoint( + x: mid.x + translatedX, + y: mid.y + dragDistanceY + ) + iconView.transform = CGAffineTransform(scaleX: innerScale, y: innerScale) } } diff --git a/Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift index a69d520..75535e5 100644 --- a/Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift +++ b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift @@ -1,5 +1,6 @@ import QuartzCore import UIKit +import Lottie // MARK: - VoiceRecordingPanelDelegate @@ -46,11 +47,34 @@ final class VoiceRecordingPanel: UIView { // MARK: - Telegram-exact layout constants - private let dotX: CGFloat = 5 // Telegram: indicator X=5 - private let timerX: CGFloat = 40 // Telegram: timer X=40 + private let dotX: CGFloat = 14 // Left margin for red dot + private let timerX: CGFloat = 38 // Timer X position private let dotSize: CGFloat = 10 + private let timerMinWidth: CGFloat = 72 private let arrowLabelGap: CGFloat = 6 + private var panelControlColor: UIColor { + UIColor { traits in + traits.userInterfaceStyle == .dark ? UIColor.white : UIColor.black + } + } + + private var panelControlAccentColor: UIColor { + UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor.white + : UIColor(red: 0, green: 136 / 255.0, blue: 1.0, alpha: 1.0) + } + } + + private var recordingDotColor: UIColor { + UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(red: 0xEB / 255.0, green: 0x55 / 255.0, blue: 0x45 / 255.0, alpha: 1.0) + : UIColor(red: 0xED / 255.0, green: 0x25 / 255.0, blue: 0x21 / 255.0, alpha: 1.0) + } + } + // MARK: - Init override init(frame: CGRect) { @@ -72,8 +96,8 @@ final class VoiceRecordingPanel: UIView { glassBackground.isUserInteractionEnabled = false addSubview(glassBackground) - // Red dot: 10×10, Telegram #FF2D55 - redDot.backgroundColor = UIColor(red: 1.0, green: 45/255.0, blue: 85/255.0, alpha: 1) + // Red dot: 10×10, theme-aware Telegram recording color. + redDot.backgroundColor = recordingDotColor redDot.layer.cornerRadius = dotSize / 2 addSubview(redDot) @@ -84,34 +108,42 @@ final class VoiceRecordingPanel: UIView { } else { timerLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .regular) } - timerLabel.textColor = .white + timerLabel.textColor = panelControlColor timerLabel.text = "0:00" + timerLabel.lineBreakMode = .byClipping + timerLabel.isAccessibilityElement = true + timerLabel.accessibilityIdentifier = "voice.recording.timer" addSubview(timerLabel) - // Arrow: exact Telegram SVG "AudioRecordingCancelArrow" (arrowleft.svg, 9×18pt) - arrowIcon.image = Self.makeCancelArrowImage() + // Arrow: Telegram asset "AudioRecordingCancelArrow" (arrowleft.svg, 9×18pt) + arrowIcon.image = VoiceRecordingAssets.image(.cancelArrow, templated: true) + arrowIcon.tintColor = panelControlColor arrowIcon.contentMode = .center cancelContainer.addSubview(arrowIcon) // "Slide to cancel": 14pt regular, panelControlColor = #FFFFFF (dark theme) slideLabel.font = .systemFont(ofSize: 14, weight: .regular) - slideLabel.textColor = .white + slideLabel.textColor = panelControlColor slideLabel.text = "Slide to cancel" cancelContainer.addSubview(slideLabel) cancelContainer.isAccessibilityElement = true cancelContainer.accessibilityLabel = "Slide left to cancel recording" + cancelContainer.accessibilityIdentifier = "voice.recording.slideToCancel" addSubview(cancelContainer) // Cancel button (for locked state): 17pt cancelButton.setTitle("Cancel", for: .normal) - cancelButton.setTitleColor(.white, for: .normal) + cancelButton.setTitleColor(panelControlAccentColor, for: .normal) cancelButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .regular) cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) cancelButton.isAccessibilityElement = true cancelButton.accessibilityLabel = "Cancel recording" cancelButton.accessibilityHint = "Discards the current recording." + cancelButton.accessibilityIdentifier = "voice.recording.cancel" cancelButton.alpha = 0 addSubview(cancelButton) + + updateThemeColors() } // MARK: - Layout @@ -137,29 +169,44 @@ final class VoiceRecordingPanel: UIView { ) // Timer: at X=34 - timerLabel.frame = CGRect(x: timerX, y: timerY, width: timerSize.width + 4, height: timerSize.height) + let timerWidth = max(timerMinWidth, timerSize.width + 4) + timerLabel.frame = CGRect(x: timerX, y: timerY, width: timerWidth, height: timerSize.height) - // Cancel indicator: centered in full panel width - // Telegram: frame.width = arrowSize.width + 12.0 + labelLayout.size.width + // Cancel indicator: centered in available space after timer let labelSize = slideLabel.sizeThatFits(CGSize(width: 200, height: h)) let arrowW: CGFloat = 9 // Telegram SVG: 9pt wide let arrowH: CGFloat = 18 // Telegram SVG: 18pt tall - let totalCancelW = arrowW + 12 + labelSize.width // Telegram: arrowWidth + 12 + labelWidth - let cancelX = floor((w - totalCancelW) / 2) + let totalCancelW = arrowW + 12 + labelSize.width + let timerTrailingX = timerX + timerWidth + let availableWidth = w - timerTrailingX + let cancelX = timerTrailingX + floor((availableWidth - totalCancelW) / 2) cancelContainer.frame = CGRect(x: cancelX, y: 0, width: totalCancelW, height: h) arrowIcon.frame = CGRect(x: 0, y: floor((h - arrowH) / 2), width: arrowW, height: arrowH) - // Telegram: label X = arrowSize.width + 6.0 slideLabel.frame = CGRect( - x: arrowW + 6, + x: arrowW + arrowLabelGap, y: 1 + floor((h - labelSize.height) / 2), width: labelSize.width, height: labelSize.height ) - // Cancel button: centered + // Cancel button: centered in available space after timer cancelButton.sizeToFit() - cancelButton.center = CGPoint(x: w / 2, y: h / 2) + cancelButton.center = CGPoint(x: timerTrailingX + availableWidth / 2, y: h / 2) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else { return } + updateThemeColors() + } + + private func updateThemeColors() { + redDot.backgroundColor = recordingDotColor + timerLabel.textColor = panelControlColor + arrowIcon.tintColor = panelControlColor + slideLabel.textColor = panelControlColor + cancelButton.setTitleColor(panelControlAccentColor, for: .normal) } // MARK: - Public API @@ -170,7 +217,29 @@ final class VoiceRecordingPanel: UIView { let minutes = totalSeconds / 60 let seconds = totalSeconds % 60 let centiseconds = Int(duration * 100) % 100 - timerLabel.text = String(format: "%d:%02d,%02d", minutes, seconds, centiseconds) + let text = String(format: "%d:%02d,%02d", minutes, seconds, centiseconds) + guard timerLabel.text != text else { return } + timerLabel.text = text + + // Keep label width live-updated so long durations never render with ellipsis. + let h = bounds.height + guard h > 0 else { + setNeedsLayout() + return + } + let timerSize = timerLabel.sizeThatFits(CGSize(width: .greatestFiniteMagnitude, height: h)) + let timerY = floor((h - timerSize.height) / 2) + 1 + let timerWidth = max(timerMinWidth, ceil(timerSize.width + 4)) + CATransaction.begin() + CATransaction.setDisableActions(true) + timerLabel.frame = CGRect(x: timerX, y: timerY, width: timerWidth, height: timerSize.height) + redDot.frame = CGRect( + x: dotX, + y: timerY + floor((timerSize.height - dotSize) / 2), + width: dotSize, + height: dotSize + ) + CATransaction.commit() } /// Updates cancel indicator position based on horizontal drag. @@ -180,14 +249,16 @@ final class VoiceRecordingPanel: UIView { // Only apply transform when actually dragging (threshold 8pt) let drag = abs(translation) - guard drag > 8 else { return } + guard VoiceRecordingParityMath.shouldApplyCancelTransform(translation) else { + cancelContainer.transform = .identity + cancelContainer.alpha = 1 + return + } - let offset = drag - 8 + let offset = drag - VoiceRecordingParityConstants.cancelTransformThreshold cancelContainer.transform = CGAffineTransform(translationX: -offset * 0.5, y: 0) - - // Fade: starts at 60% of cancel threshold (90pt drag), fully hidden at threshold - let fadeProgress = max(0, min(1, (drag - 90) / 60)) - cancelContainer.alpha = 1 - fadeProgress + let currentMinX = cancelContainer.frame.minX + cancelContainer.transform.tx + cancelContainer.alpha = max(0, min(1, (currentMinX - 100) / 10)) } /// Animate panel in. Called when recording begins. @@ -208,7 +279,6 @@ final class VoiceRecordingPanel: UIView { // Timer: slide in from left, spring 0.5s timerLabel.alpha = 0 - let timerStartX = timerLabel.frame.origin.x - 30 timerLabel.transform = CGAffineTransform(translationX: -30, y: 0) UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.55, initialSpringVelocity: 0, options: []) { self.timerLabel.alpha = 1 @@ -252,35 +322,68 @@ final class VoiceRecordingPanel: UIView { func animateOutCancel(completion: (() -> Void)? = nil) { stopDotPulsing() stopCancelJiggle() + isUserInteractionEnabled = false - // Red dot: scale pulse 1→1.3→0, color red→gray - UIView.animate(withDuration: 0.15, animations: { - self.redDot.transform = CGAffineTransform(scaleX: 1.3, y: 1.3) - self.redDot.backgroundColor = .gray - }, completion: { _ in + var didFinishBin = false + var didFinishRest = false + let completeIfReady: () -> Void = { [weak self] in + guard let self else { return } + if didFinishBin && didFinishRest { + self.removeFromSuperview() + completion?() + } + } + + // Telegram parity: on cancel, panel content disappears quickly while + // bin animation keeps playing near the leading edge. + let indicatorFrame = CGRect(x: 0, y: floor((bounds.height - 40) / 2.0), width: 40, height: 40) + let binHostView = superview ?? self + let binFrameInHost = convert(indicatorFrame, to: binHostView) + if let animation = LottieAnimation.named(VoiceRecordingLottieAsset.binRed.rawValue) { + let binView = LottieAnimationView(animation: animation) + binView.frame = binFrameInHost + binView.backgroundBehavior = .pauseAndRestore + binView.contentMode = .scaleAspectFit + binView.loopMode = .playOnce + binHostView.addSubview(binView) + redDot.alpha = 0 + binView.play { _ in + binView.removeFromSuperview() + didFinishBin = true + completeIfReady() + } + } else { + didFinishBin = true UIView.animate(withDuration: 0.15, animations: { - self.redDot.transform = CGAffineTransform(scaleX: 0.01, y: 0.01) - self.redDot.alpha = 0 + self.redDot.transform = CGAffineTransform(scaleX: 1.3, y: 1.3) + self.redDot.backgroundColor = .gray + }, completion: { _ in + UIView.animate(withDuration: 0.15, animations: { + self.redDot.transform = CGAffineTransform(scaleX: 0.01, y: 0.01) + self.redDot.alpha = 0 + }) }) - }) + } - // Timer: scale to 0, slide left - UIView.animate(withDuration: 0.25) { + // Timer: scale to 0, slide left. + UIView.animate(withDuration: 0.2) { self.timerLabel.transform = CGAffineTransform(translationX: -30, y: 0) .scaledBy(x: 0.001, y: 0.001) self.timerLabel.alpha = 0 } - // Cancel indicator: fade out - UIView.animate(withDuration: 0.25) { + // Hide panel visuals quickly so only trash animation remains visible. + UIView.animate(withDuration: 0.12) { + self.glassBackground.alpha = 0 + self.redDot.alpha = 0 self.cancelContainer.alpha = 0 self.cancelButton.alpha = 0 } // Remove after animation completes - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in - self?.removeFromSuperview() - completion?() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) { + didFinishRest = true + completeIfReady() } } @@ -344,49 +447,4 @@ final class VoiceRecordingPanel: UIView { @objc private func cancelTapped() { delegate?.recordingPanelDidTapCancel(self) } - - // MARK: - Telegram Cancel Arrow (exact SVG from arrowleft.svg, 9×18pt) - - private static func makeCancelArrowImage() -> UIImage { - let size = CGSize(width: 9, height: 18) - let renderer = UIGraphicsImageRenderer(size: size) - return renderer.image { ctx in - let path = UIBezierPath() - // Exact path from Telegram's arrowleft.svg - path.move(to: CGPoint(x: 8.438, y: 0.500)) - path.addCurve( - to: CGPoint(x: 8.500, y: 1.438), - controlPoint1: CGPoint(x: 8.714, y: 0.741), - controlPoint2: CGPoint(x: 8.742, y: 1.161) - ) - path.addLine(to: CGPoint(x: 1.884, y: 9.000)) - path.addLine(to: CGPoint(x: 8.500, y: 16.562)) - path.addCurve( - to: CGPoint(x: 8.438, y: 17.500), - controlPoint1: CGPoint(x: 8.742, y: 16.839), - controlPoint2: CGPoint(x: 8.714, y: 17.259) - ) - path.addCurve( - to: CGPoint(x: 7.500, y: 17.438), - controlPoint1: CGPoint(x: 8.161, y: 17.742), - controlPoint2: CGPoint(x: 7.741, y: 17.714) - ) - path.addLine(to: CGPoint(x: 0.499, y: 9.438)) - path.addCurve( - to: CGPoint(x: 0.499, y: 8.562), - controlPoint1: CGPoint(x: 0.280, y: 9.187), - controlPoint2: CGPoint(x: 0.280, y: 8.813) - ) - path.addLine(to: CGPoint(x: 7.500, y: 0.562)) - path.addCurve( - to: CGPoint(x: 8.438, y: 0.500), - controlPoint1: CGPoint(x: 7.741, y: 0.286), - controlPoint2: CGPoint(x: 8.161, y: 0.258) - ) - path.close() - - UIColor.white.setFill() - path.fill() - } - } } diff --git a/Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift new file mode 100644 index 0000000..dce52d6 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift @@ -0,0 +1,92 @@ +import CoreGraphics +import Foundation + +enum VoiceRecordingReleaseDecision: String { + case finish + case cancel + case lock +} + +enum VoiceRecordingParityConstants { + static let holdThreshold: TimeInterval = 0.19 + static let cancelDistanceThreshold: CGFloat = -150 + static let cancelHapticThreshold: CGFloat = -100 + static let lockDistanceThreshold: CGFloat = -110 + static let lockHapticThreshold: CGFloat = -60 + static let velocityGate: CGFloat = -400 + static let preHoldCancelDistance: CGFloat = 10 + static let micHitInsetX: CGFloat = -10 + + static let locknessDivisor: CGFloat = 105 + static let dragNormalizeDivisor: CGFloat = 300 + static let cancelTransformThreshold: CGFloat = 8 + + static let sendAccessibilityHitSize: CGFloat = 120 + static let minVoiceDuration: TimeInterval = 0.5 + static let minFreeDiskBytes: Int64 = 8 * 1024 * 1024 + + static func minTrimDuration(duration: TimeInterval, waveformWidth: CGFloat) -> TimeInterval { + max(1.0, 56.0 * duration / max(waveformWidth, 1)) + } +} + +enum VoiceRecordingParityMath { + static func dominantAxisDistances(distanceX: CGFloat, distanceY: CGFloat) -> (CGFloat, CGFloat) { + if abs(distanceX) > abs(distanceY) { + return (distanceX, 0) + } else { + return (0, distanceY) + } + } + + static func releaseDecision( + velocityX: CGFloat, + velocityY: CGFloat, + distanceX: CGFloat, + distanceY: CGFloat + ) -> VoiceRecordingReleaseDecision { + if velocityX < VoiceRecordingParityConstants.velocityGate || + distanceX < VoiceRecordingParityConstants.cancelHapticThreshold { + return .cancel + } + if velocityY < VoiceRecordingParityConstants.velocityGate || + distanceY < VoiceRecordingParityConstants.lockHapticThreshold { + return .lock + } + return .finish + } + + static func lockness(distanceY: CGFloat) -> CGFloat { + min(1, max(0, abs(distanceY) / VoiceRecordingParityConstants.locknessDivisor)) + } + + static func normalizedDrag(distance: CGFloat) -> CGFloat { + max(0, min(1, abs(distance) / VoiceRecordingParityConstants.dragNormalizeDivisor)) + } + + static func shouldApplyCancelTransform(_ translation: CGFloat) -> Bool { + abs(translation) > VoiceRecordingParityConstants.cancelTransformThreshold + } + + static func shouldDiscard(duration: TimeInterval) -> Bool { + duration < VoiceRecordingParityConstants.minVoiceDuration + } + + static func clampTrimRange(_ trimRange: ClosedRange, duration: TimeInterval) -> ClosedRange { + let lower = max(0, min(trimRange.lowerBound, duration)) + let upper = max(lower, min(trimRange.upperBound, duration)) + return lower...upper + } + + static func waveformSliceRange( + sampleCount: Int, + totalDuration: TimeInterval, + trimRange: ClosedRange + ) -> Range? { + guard sampleCount > 0, totalDuration > 0 else { return nil } + let startIndex = max(0, Int(floor((trimRange.lowerBound / totalDuration) * Double(sampleCount)))) + let endIndex = min(sampleCount, Int(ceil((trimRange.upperBound / totalDuration) * Double(sampleCount)))) + guard startIndex < endIndex else { return nil } + return startIndex.. VoiceRecordingUITestFixtureContainer { + let view = VoiceRecordingUITestFixtureContainer(mode: mode) + view.accessibilityIdentifier = "voice.fixture.root" + return view + } + + func updateUIView(_ uiView: VoiceRecordingUITestFixtureContainer, context: Context) {} +} + +struct VoiceRecordingUITestFixtureView: View { + private let mode: VoiceRecordingUITestFixtureContainer.Mode + + init(modeRawValue: String) { + self.mode = VoiceRecordingUITestFixtureContainer.Mode(rawValue: modeRawValue) ?? .idle + } + + var body: some View { + VStack(spacing: 12) { + Text("Voice Recording Fixture") + .font(.headline) + Text("Mode: \(mode.rawValue)") + .font(.footnote) + .foregroundStyle(.secondary) + VoiceRecordingUITestFixtureRepresentable(mode: mode) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .padding(.top, 24) + .padding(.horizontal, 12) + .background(Color(.systemBackground)) + .accessibilityIdentifier("voice.fixture.screen") + } +} diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index 70f8b8a..10722a5 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -647,7 +647,9 @@ private struct ChatListDialogContent: View { @State private var typingDialogs: [String: Set] = [:] var body: some View { + #if DEBUG let _ = PerformanceLogger.shared.track("chatList.bodyEval") + #endif // CRITICAL: Read DialogRepository.dialogs directly to establish @Observable tracking. // Without this, ChatListDialogContent only observes viewModel (ObservableObject) // which never publishes objectWillChange for dialog mutations. @@ -725,78 +727,6 @@ private struct ChatListDialogContent: View { } } -// MARK: - Sync-Aware Chat Row (observation-isolated) - -/// Reads `SessionManager.syncBatchInProgress` (@Observable) in its own -/// observation scope. Without this wrapper, every sync state change would -/// invalidate the entire `ChatListDialogContent.body` and rebuild all rows. -/// Reads `SessionManager.syncBatchInProgress` (@Observable) in its own -/// observation scope. Without this wrapper, every sync state change would -/// invalidate the entire `ChatListDialogContent.body` and rebuild all rows. -/// -/// **Performance:** `viewModel` and `navigationState` are stored as plain `let` -/// (not @ObservedObject). Class references compare by pointer in SwiftUI's -/// memcmp-based view diffing — stable pointers mean unchanged rows are NOT -/// re-evaluated when the parent body rebuilds. Closures are defined inline -/// (not passed from parent) to avoid non-diffable closure props that force -/// every row dirty on every parent re-render. -struct SyncAwareChatRow: View { - let dialog: Dialog - let isTyping: Bool - let typingSenderNames: [String] - let isFirst: Bool - let viewModel: ChatListViewModel - let navigationState: ChatListNavigationState - - var body: some View { - let isSyncing = SessionManager.shared.syncBatchInProgress - Button { - navigationState.path.append(ChatRoute(dialog: dialog)) - } label: { - ChatRowView( - dialog: dialog, - isSyncing: isSyncing, - isTyping: isTyping, - typingSenderNames: typingSenderNames - ) - } - .buttonStyle(.plain) - .listRowInsets(EdgeInsets()) - .listRowSeparator(isFirst ? .hidden : .visible, edges: .top) - .listRowSeparator(.visible, edges: .bottom) - .listRowSeparatorTint(RosettaColors.Adaptive.divider) - .alignmentGuide(.listRowSeparatorLeading) { _ in 82 } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - withAnimation { viewModel.deleteDialog(dialog) } - } label: { - Label("Delete", systemImage: "trash") - } - - if !dialog.isSavedMessages { - Button { - withAnimation { viewModel.toggleMute(dialog) } - } label: { - Label( - dialog.isMuted ? "Unmute" : "Mute", - systemImage: dialog.isMuted ? "bell" : "bell.slash" - ) - } - .tint(dialog.isMuted ? .green : .indigo) - } - } - .swipeActions(edge: .leading, allowsFullSwipe: true) { - Button { - withAnimation { viewModel.togglePin(dialog) } - } label: { - Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: dialog.isPinned ? "pin.slash" : "pin") - } - .tint(.orange) - } - } -} - - // MARK: - Device Approval Banner /// Desktop parity: clean banner with "New login from {device} ({os})" and Accept/Decline. diff --git a/Rosetta/Features/Chats/ChatList/ChatRowView.swift b/Rosetta/Features/Chats/ChatList/ChatRowView.swift deleted file mode 100644 index 76cb424..0000000 --- a/Rosetta/Features/Chats/ChatList/ChatRowView.swift +++ /dev/null @@ -1,459 +0,0 @@ -import SwiftUI -import Combine - -// MARK: - ChatRowView - -/// Chat row matching Figma "Row - Chats" component spec (node 3994:38947): -/// -/// Row: height 78, pl-10, pr-16, items-center -/// Avatar: 62px circle, pr-10 -/// Contents: flex-col, h-full, items-start, justify-center, pb-px -/// Title and Trailing Accessories: flex-1, gap-6, items-center, w-full -/// Title and Detail: flex-1, h-63, items-start, overflow-clip -/// Title: gap-4, items-center — SF Pro Medium 17/22, tracking -0.43 -/// Message: h-41 — SF Pro Regular 15/20, tracking -0.23, secondary -/// Accessories: h-full, items-center, justify-end -/// Contents-Trailing: flex-col, h-full, items-end, justify-between, pt-8 -/// Time: SF Pro Regular 14/20, tracking -0.23, secondary -/// Other: flex-1, items-end, justify-end, pb-14 -/// Badge: bg-#008BFF, min-w-20, max-w-37, px-4, rounded-full -/// SF Pro Regular 15/20, black, tracking -0.23 -struct ChatRowView: View { - let dialog: Dialog - /// Desktop parity: suppress unread badge during sync. - var isSyncing: Bool = false - /// Desktop parity: show "typing..." instead of last message. - var isTyping: Bool = false - /// Group typing: sender names for "Name typing..." / "Name and N typing..." display. - var typingSenderNames: [String] = [] - - - var displayTitle: String { - if dialog.isSavedMessages { return "Saved Messages" } - if dialog.isGroup { - let meta = GroupRepository.shared.groupMetadata( - account: dialog.account, - groupDialogKey: dialog.opponentKey - ) - if let title = meta?.title, !title.isEmpty { return title } - } - if !dialog.opponentTitle.isEmpty { return dialog.opponentTitle } - if !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" } - return String(dialog.opponentKey.prefix(12)) - } - - var body: some View { - let _ = PerformanceLogger.shared.track("chatRow.bodyEval") - HStack(spacing: 0) { - avatarSection - .padding(.trailing, 10) - - contentSection - } - .padding(.leading, 10) - .padding(.trailing, 16) - .frame(height: 78) - .contentShape(Rectangle()) - } -} - -// MARK: - Avatar - -/// Observation-isolated: reads `AvatarRepository.avatarVersion` in its own -/// scope so only the avatar re-renders when opponent avatar changes — not the -/// entire ChatRowView (title, message preview, badge, etc.). -private struct ChatRowAvatar: View { - let dialog: Dialog - - var body: some View { - if dialog.isGroup { - groupAvatarView - } else { - directAvatarView - } - } - - private var directAvatarView: some View { - // Establish @Observable tracking — re-renders this view on avatar save/remove. - let _ = AvatarRepository.shared.avatarVersion - return AvatarView( - initials: dialog.initials, - colorIndex: dialog.avatarColorIndex, - size: 62, - isOnline: dialog.isOnline, - isSavedMessages: dialog.isSavedMessages, - image: dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey) - ) - } - - private var groupAvatarView: some View { - let _ = AvatarRepository.shared.avatarVersion - let groupImage = AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey) - return ZStack { - if let image = groupImage { - Image(uiImage: image) - .resizable() - .scaledToFill() - .frame(width: 62, height: 62) - .clipShape(Circle()) - } else { - Circle() - .fill(RosettaColors.avatarColors[dialog.avatarColorIndex % RosettaColors.avatarColors.count].tint) - .frame(width: 62, height: 62) - Image(systemName: "person.2.fill") - .font(.system(size: 24, weight: .medium)) - .foregroundStyle(.white.opacity(0.9)) - } - } - } -} - -private extension ChatRowView { - var avatarSection: some View { - ChatRowAvatar(dialog: dialog) - } -} - -// MARK: - Content Section -// Figma "Contents": flex-col, h-full, items-start, justify-center, pb-px -// └─ "Title and Trailing Accessories": flex-1, gap-6, items-center - -private extension ChatRowView { - var contentSection: some View { - HStack(alignment: .center, spacing: 6) { - // "Title and Detail": flex-1, h-63, items-start, overflow-clip - VStack(alignment: .leading, spacing: 0) { - titleRow - messageRow - } - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 63) - .clipped() - - // "Accessories and Grabber": h-full, items-center, justify-end - trailingColumn - .frame(maxHeight: .infinity) - } - .frame(maxHeight: .infinity) - .padding(.bottom, 1) - } -} - -// MARK: - Title Row (name + badges) -// Figma "Title": gap-4, items-center, w-full - -private extension ChatRowView { - var titleRow: some View { - HStack(spacing: 4) { - Text(displayTitle) - .font(.system(size: 17, weight: .medium)) - .tracking(-0.43) - .foregroundStyle(RosettaColors.Adaptive.text) - .lineLimit(1) - - if !dialog.isSavedMessages && dialog.effectiveVerified > 0 { - VerifiedBadge( - verified: dialog.effectiveVerified, - size: 16 - ) - } - - if dialog.isMuted { - Image(systemName: "speaker.slash.fill") - .font(.system(size: 12)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) - } - } - } -} - -// MARK: - Message Row -// Figma "Message": h-41, SF Pro Regular 15/20, tracking -0.23, secondary - -private extension ChatRowView { - var messageRow: some View { - Text(messageText) - .font(.system(size: 15)) - .tracking(-0.23) - .foregroundStyle( - isTyping && !dialog.isSavedMessages - ? RosettaColors.figmaBlue - : RosettaColors.Adaptive.textSecondary - ) - .lineLimit(2) - .frame(height: 41, alignment: .topLeading) - } - - /// Static cache for emoji-parsed message text (avoids regex per row per render). - private static var messageTextCache: [String: String] = [:] - - var messageText: String { - // Desktop parity: show "typing..." in chat list row when opponent is typing. - if isTyping && !dialog.isSavedMessages { - if dialog.isGroup && !typingSenderNames.isEmpty { - if typingSenderNames.count == 1 { - return "\(typingSenderNames[0]) typing..." - } else { - return "\(typingSenderNames[0]) and \(typingSenderNames.count - 1) typing..." - } - } - return "typing..." - } - let raw = dialog.lastMessage.trimmingCharacters(in: .whitespacesAndNewlines) - if raw.isEmpty { - return "No messages yet" - } - // Desktop parity: show "Group invite" for #group: invite messages. - if raw.hasPrefix("#group:") { - return "Group invite" - } - // Safety net: never show encrypted ciphertext (ivBase64:ctBase64) to user. - // This catches stale data persisted before isGarbageText was improved. - if Self.looksLikeCiphertext(raw) { - return "No messages yet" - } - if let cached = Self.messageTextCache[dialog.lastMessage] { - return cached - } - // Strip inline markdown markers and convert emoji shortcodes for clean preview. - let cleaned = dialog.lastMessage.replacingOccurrences(of: "**", with: "") - let result = EmojiParser.replaceShortcodes(in: cleaned) - if Self.messageTextCache.count > 500 { - let keysToRemove = Array(Self.messageTextCache.keys.prefix(250)) - for key in keysToRemove { Self.messageTextCache.removeValue(forKey: key) } - } - Self.messageTextCache[dialog.lastMessage] = result - return result - } - - /// Detects encrypted payload formats that should never be shown in UI. - private static func looksLikeCiphertext(_ text: String) -> Bool { - // CHNK: chunked format - if text.hasPrefix("CHNK:") { return true } - // ivBase64:ctBase64 or hex-encoded XChaCha20 ciphertext - let parts = text.components(separatedBy: ":") - if parts.count == 2 { - let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/=")) - let bothBase64 = parts.allSatisfy { part in - part.count >= 16 && part.unicodeScalars.allSatisfy { base64Chars.contains($0) } - } - if bothBase64 { return true } - } - // Pure hex string (≥40 chars, only hex digits) — XChaCha20 wire format - if text.count >= 40 { - let hexChars = CharacterSet(charactersIn: "0123456789abcdefABCDEF") - if text.unicodeScalars.allSatisfy({ hexChars.contains($0) }) { return true } - } - return false - } -} - -// MARK: - Trailing Column -// Figma "Contents - Trailing": flex-col, h-full, items-end, justify-between, pt-8 -// ├─ "Read Status and Time": gap-2, items-center -// └─ "Other": flex-1, items-end, justify-end, pb-14 - -private extension ChatRowView { - var trailingColumn: some View { - VStack(alignment: .trailing, spacing: 0) { - // Top: read status + time - HStack(spacing: 2) { - if dialog.lastMessageFromMe && !dialog.isSavedMessages { - deliveryIcon - } - - Text(formattedTime) - .font(.system(size: 14)) - .tracking(-0.23) - .foregroundStyle( - dialog.unreadCount > 0 && !dialog.isMuted - ? RosettaColors.figmaBlue - : RosettaColors.Adaptive.textSecondary - ) - } - .padding(.top, 8) - - Spacer(minLength: 0) - - // Bottom: pin or unread badge - HStack(spacing: 8) { - if dialog.isPinned && dialog.unreadCount == 0 { - Image(systemName: "pin.fill") - .font(.system(size: 15)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) - .rotationEffect(.degrees(45)) - } - - // Show unread badge whenever there are unread messages. - // Previously hidden when lastMessageFromMe (desktop parity), - // but this caused invisible unreads when user sent a reply - // without reading prior incoming messages first. - if dialog.hasMention && dialog.unreadCount > 0 && !isSyncing { - mentionBadge - } - if dialog.unreadCount > 0 && !isSyncing { - unreadBadge - } - } - .padding(.bottom, 14) - } - } - - /// Telegram-style `@` mention indicator (shown left of unread count). - var mentionBadge: some View { - Text("@") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(.white) - .frame(width: 20, height: 20) - .background { - Circle() - .fill(dialog.isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue) - } - } - - @ViewBuilder - var deliveryIcon: some View { - if dialog.lastMessageDelivered == .delivered && dialog.lastMessageRead { - DoubleCheckmarkShape() - .fill(RosettaColors.figmaBlue) - .frame(width: 17, height: 9.3) - } else { - switch dialog.lastMessageDelivered { - case .waiting: - // Timer isolated to sub-view — only .waiting rows create a timer. - DeliveryWaitingIcon(sentTimestamp: dialog.lastMessageTimestamp) - case .delivered: - SingleCheckmarkShape() - .fill(RosettaColors.Adaptive.textSecondary) - .frame(width: 14, height: 10.3) - case .error: - Image(systemName: "exclamationmark.circle.fill") - .font(.system(size: 14)) - .foregroundStyle(RosettaColors.error) - } - } - } - - var unreadBadge: some View { - let count = dialog.unreadCount - let text = count > 999 ? "\(count / 1000)K" : (count > 99 ? "99+" : "\(count)") - let isMuted = dialog.isMuted - let isSmall = count < 10 - - return Text(text) - .font(.system(size: 15)) - .tracking(-0.23) - .foregroundStyle(.white) - .padding(.horizontal, isSmall ? 0 : 4) - .frame( - minWidth: 20, - maxWidth: isSmall ? 20 : 37, - minHeight: 20 - ) - .background { - Capsule() - .fill(isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue) - } - } -} - -// MARK: - Delivery Waiting Icon (timer-isolated) - -/// Desktop parity: clock → error after 80s. Timer only exists on rows with -/// `.waiting` delivery status — all other rows have zero timer overhead. -private struct DeliveryWaitingIcon: View { - let sentTimestamp: Int64 - @State private var now = Date() - private let recheckTimer = Timer.publish(every: 40, on: .main, in: .common).autoconnect() - - private var isWithinWindow: Bool { - guard sentTimestamp > 0 else { return true } - let sentDate = Date(timeIntervalSince1970: Double(sentTimestamp) / 1000) - return now.timeIntervalSince(sentDate) < 80 - } - - var body: some View { - Group { - if isWithinWindow { - Image(systemName: "clock") - .font(.system(size: 13)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) - } else { - Image(systemName: "exclamationmark.circle.fill") - .font(.system(size: 14)) - .foregroundStyle(RosettaColors.error) - } - } - .onReceive(recheckTimer) { now = $0 } - } -} - -// MARK: - Time Formatting - -private extension ChatRowView { - private static let timeFormatter: DateFormatter = { - let f = DateFormatter(); f.dateFormat = "h:mm a"; return f - }() - private static let dayFormatter: DateFormatter = { - let f = DateFormatter(); f.dateFormat = "EEE"; return f - }() - private static let dateFormatter: DateFormatter = { - let f = DateFormatter(); f.dateFormat = "dd.MM.yy"; return f - }() - - /// Static cache for formatted time strings (avoids Date/Calendar per row per render). - private static var timeStringCache: [Int64: String] = [:] - - var formattedTime: String { - guard dialog.lastMessageTimestamp > 0 else { return "" } - - if let cached = Self.timeStringCache[dialog.lastMessageTimestamp] { - return cached - } - - let date = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000) - let now = Date() - let calendar = Calendar.current - - let result: String - if calendar.isDateInToday(date) { - result = Self.timeFormatter.string(from: date) - } else if calendar.isDateInYesterday(date) { - result = "Yesterday" - } else if let days = calendar.dateComponents([.day], from: date, to: now).day, days < 7 { - result = Self.dayFormatter.string(from: date) - } else { - result = Self.dateFormatter.string(from: date) - } - - if Self.timeStringCache.count > 500 { - let keysToRemove = Array(Self.timeStringCache.keys.prefix(250)) - for key in keysToRemove { Self.timeStringCache.removeValue(forKey: key) } - } - Self.timeStringCache[dialog.lastMessageTimestamp] = result - return result - } -} - -// MARK: - Preview - -#Preview { - let sampleDialog = Dialog( - id: "preview", account: "mykey", opponentKey: "abc001", - opponentTitle: "Alice Johnson", - opponentUsername: "alice", - lastMessage: "Hey, how are you?", - lastMessageTimestamp: Int64(Date().timeIntervalSince1970 * 1000), - unreadCount: 3, isOnline: true, lastSeen: 0, - verified: 1, iHaveSent: true, - isPinned: false, isMuted: false, - lastMessageFromMe: true, lastMessageDelivered: .delivered, - lastMessageRead: true - ) - - VStack(spacing: 0) { - ChatRowView(dialog: sampleDialog) - ChatRowView(dialog: sampleDialog, isTyping: true) - } - .background(RosettaColors.Adaptive.background) -} diff --git a/Rosetta/Features/Chats/ChatList/RequestChatsView.swift b/Rosetta/Features/Chats/ChatList/RequestChatsView.swift index 55e4498..7916c12 100644 --- a/Rosetta/Features/Chats/ChatList/RequestChatsView.swift +++ b/Rosetta/Features/Chats/ChatList/RequestChatsView.swift @@ -1,34 +1,33 @@ import Lottie import SwiftUI +import UIKit + +// MARK: - RequestChatsView (SwiftUI shell — toolbar + navigation only) /// Screen showing incoming message requests — opened from the "Request Chats" /// row at the top of the main chat list (Telegram Archive style). +/// List content rendered by UIKit RequestChatsController for performance parity. struct RequestChatsView: View { @ObservedObject var viewModel: ChatListViewModel @ObservedObject var navigationState: ChatListNavigationState @Environment(\.dismiss) private var dismiss - /// Desktop parity: track typing dialogs from MessageRepository (@Published). - @State private var typingDialogs: [String: Set] = [:] - var body: some View { Group { if viewModel.requestsModeDialogs.isEmpty { RequestsEmptyStateView() } else { - List { - ForEach(Array(viewModel.requestsModeDialogs.enumerated()), id: \.element.id) { index, dialog in - requestRow(dialog, isFirst: index == 0) + let isSyncing = SessionManager.shared.syncBatchInProgress + RequestChatsCollectionView( + dialogs: viewModel.requestsModeDialogs, + isSyncing: isSyncing, + onSelectDialog: { dialog in + navigationState.path.append(ChatRoute(dialog: dialog)) + }, + onDeleteDialog: { dialog in + viewModel.deleteDialog(dialog) } - - Color.clear.frame(height: 80) - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .scrollIndicators(.hidden) + ) } } .background(RosettaColors.Adaptive.background.ignoresSafeArea()) @@ -50,7 +49,6 @@ struct RequestChatsView: View { } .modifier(ChatListToolbarBackgroundModifier()) .enableSwipeBack() - .onReceive(MessageRepository.shared.$typingDialogs) { typingDialogs = $0 } } // MARK: - Capsule Back Button (matches ChatDetailView) @@ -67,30 +65,173 @@ struct RequestChatsView: View { .frame(height: 44) .padding(.horizontal, 4) .background { - glassCapsule(strokeOpacity: 0.22, strokeColor: .white) + TelegramGlassCapsule() + } + } +} + +// MARK: - RequestChatsCollectionView (UIViewControllerRepresentable bridge) + +private struct RequestChatsCollectionView: UIViewControllerRepresentable { + let dialogs: [Dialog] + let isSyncing: Bool + var onSelectDialog: ((Dialog) -> Void)? + var onDeleteDialog: ((Dialog) -> Void)? + + func makeUIViewController(context: Context) -> RequestChatsController { + let controller = RequestChatsController() + controller.onSelectDialog = onSelectDialog + controller.onDeleteDialog = onDeleteDialog + controller.updateDialogs(dialogs, isSyncing: isSyncing) + return controller + } + + func updateUIViewController(_ controller: RequestChatsController, context: Context) { + controller.onSelectDialog = onSelectDialog + controller.onDeleteDialog = onDeleteDialog + controller.updateDialogs(dialogs, isSyncing: isSyncing) + } +} + +// MARK: - RequestChatsController (UIKit) + +/// Pure UIKit UICollectionView controller for request chats list. +/// Single flat section with ChatListCell — same rendering as main chat list. +final class RequestChatsController: UIViewController { + + var onSelectDialog: ((Dialog) -> Void)? + var onDeleteDialog: ((Dialog) -> Void)? + + private var dialogs: [Dialog] = [] + private var isSyncing: Bool = false + private var dialogMap: [String: Dialog] = [:] + + private var collectionView: UICollectionView! + private var dataSource: UICollectionViewDiffableDataSource! + private var cellRegistration: UICollectionView.CellRegistration! + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + setupCollectionView() + setupCellRegistration() + setupDataSource() + } + + // MARK: - Collection View + + private func setupCollectionView() { + var listConfig = UICollectionLayoutListConfiguration(appearance: .plain) + listConfig.showsSeparators = false + listConfig.backgroundColor = .clear + listConfig.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in + self?.trailingSwipeActions(for: indexPath) + } + + let layout = UICollectionViewCompositionalLayout.list(using: listConfig) + + collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.backgroundColor = .clear + collectionView.delegate = self + collectionView.showsVerticalScrollIndicator = false + collectionView.alwaysBounceVertical = true + collectionView.contentInset.bottom = 80 + view.addSubview(collectionView) + + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + private func setupCellRegistration() { + cellRegistration = UICollectionView.CellRegistration { + [weak self] cell, indexPath, dialog in + guard let self else { return } + cell.configure(with: dialog, isSyncing: self.isSyncing) + cell.setSeparatorHidden(indexPath.item == 0) } } - // Use TelegramGlass* for ALL iOS versions — SwiftUI .glassEffect() blocks touches. - private func glassCapsule(strokeOpacity: Double = 0.18, strokeColor: Color = .white) -> some View { - TelegramGlassCapsule() + private func setupDataSource() { + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { [weak self] collectionView, indexPath, itemId in + guard let self, let dialog = self.dialogMap[itemId] else { + return UICollectionViewCell() + } + return collectionView.dequeueConfiguredReusableCell( + using: self.cellRegistration, + for: indexPath, + item: dialog + ) + } } - private func requestRow(_ dialog: Dialog, isFirst: Bool) -> some View { - SyncAwareChatRow( - dialog: dialog, - isTyping: !(typingDialogs[dialog.opponentKey]?.isEmpty ?? true), - typingSenderNames: { - guard let senderKeys = typingDialogs[dialog.opponentKey] else { return [] } - return senderKeys.map { sk in - DialogRepository.shared.dialogs[sk]?.opponentTitle - ?? String(sk.prefix(8)) - } - }(), - isFirst: isFirst, - viewModel: viewModel, - navigationState: navigationState - ) + // MARK: - Update Data + + func updateDialogs(_ newDialogs: [Dialog], isSyncing: Bool) { + self.isSyncing = isSyncing + + let oldIds = dialogs.map(\.id) + let newIds = newDialogs.map(\.id) + let structureChanged = oldIds != newIds + + self.dialogs = newDialogs + dialogMap.removeAll(keepingCapacity: true) + for d in newDialogs { dialogMap[d.id] = d } + + guard dataSource != nil else { return } + + if structureChanged { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(newIds, toSection: 0) + dataSource.apply(snapshot, animatingDifferences: true) + } + + // Reconfigure visible cells + for cell in collectionView.visibleCells { + guard let indexPath = collectionView.indexPath(for: cell), + let itemId = dataSource.itemIdentifier(for: indexPath), + let chatCell = cell as? ChatListCell, + let dialog = dialogMap[itemId] else { continue } + chatCell.configure(with: dialog, isSyncing: isSyncing) + } + } + + // MARK: - Swipe Actions + + private func trailingSwipeActions(for indexPath: IndexPath) -> UISwipeActionsConfiguration? { + guard let itemId = dataSource.itemIdentifier(for: indexPath), + let dialog = dialogMap[itemId] else { return nil } + + let delete = UIContextualAction(style: .destructive, title: nil) { [weak self] _, _, completion in + DispatchQueue.main.async { self?.onDeleteDialog?(dialog) } + completion(true) + } + delete.image = UIImage(systemName: "trash.fill") + delete.backgroundColor = UIColor(red: 1, green: 0.23, blue: 0.19, alpha: 1) + + return UISwipeActionsConfiguration(actions: [delete]) + } +} + +// MARK: - UICollectionViewDelegate + +extension RequestChatsController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + collectionView.deselectItem(at: indexPath, animated: true) + guard let itemId = dataSource.itemIdentifier(for: indexPath), + let dialog = dialogMap[itemId] else { return } + DispatchQueue.main.async { [weak self] in + self?.onSelectDialog?(dialog) + } } } diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift index c7eb3ff..ad2d1e0 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift @@ -58,8 +58,7 @@ final class ChatListCell: UICollectionViewCell { let statusImageView = UIImageView() let badgeContainer = UIView() let badgeLabel = UILabel() - let mentionBadgeContainer = UIView() - let mentionLabel = UILabel() + let mentionImageView = UIImageView() let pinnedIconView = UIImageView() // Separator @@ -173,16 +172,11 @@ final class ChatListCell: UICollectionViewCell { badgeLabel.textAlignment = .center badgeContainer.addSubview(badgeLabel) - // Mention badge - mentionBadgeContainer.isHidden = true - mentionBadgeContainer.layer.cornerRadius = CellLayout.badgeDiameter / 2 - contentView.addSubview(mentionBadgeContainer) - - mentionLabel.font = .systemFont(ofSize: 14, weight: .medium) - mentionLabel.textColor = .white - mentionLabel.text = "@" - mentionLabel.textAlignment = .center - mentionBadgeContainer.addSubview(mentionLabel) + // Mention badge (Telegram-exact: tinted vector icon) + mentionImageView.image = UIImage(named: "MentionBadgeIcon")?.withRenderingMode(.alwaysTemplate) + mentionImageView.contentMode = .scaleAspectFit + mentionImageView.isHidden = true + contentView.addSubview(mentionImageView) // Pin icon pinnedIconView.contentMode = .scaleAspectFit @@ -310,13 +304,12 @@ final class ChatListCell: UICollectionViewCell { badgeRightEdge = badgeContainer.frame.minX - CellLayout.badgeSpacing } - if !mentionBadgeContainer.isHidden { - mentionBadgeContainer.frame = CGRect( + if !mentionImageView.isHidden { + mentionImageView.frame = CGRect( x: badgeRightEdge - CellLayout.badgeDiameter, y: badgeY, width: CellLayout.badgeDiameter, height: CellLayout.badgeDiameter ) - mentionLabel.frame = mentionBadgeContainer.bounds - badgeRightEdge = mentionBadgeContainer.frame.minX - CellLayout.badgeSpacing + badgeRightEdge = mentionImageView.frame.minX - CellLayout.badgeSpacing } if !pinnedIconView.isHidden { @@ -420,7 +413,7 @@ final class ChatListCell: UICollectionViewCell { // Date dateLabel.text = formatTime(dialog.lastMessageTimestamp) - dateLabel.textColor = (dialog.unreadCount > 0 && !dialog.isMuted) ? accentBlue : secondaryColor + dateLabel.textColor = secondaryColor // Delivery status configureDeliveryStatus(dialog: dialog, secondaryColor: secondaryColor, accentBlue: accentBlue) @@ -584,7 +577,9 @@ final class ChatListCell: UICollectionViewCell { private func configureBadge(dialog: Dialog, isSyncing: Bool, accentBlue: UIColor, mutedBadgeBg: UIColor) { let count = dialog.unreadCount - let showBadge = count > 0 && !isSyncing + // Telegram: when mention + only 1 unread → show only @ badge, no count + let showMention = dialog.hasMention && count > 0 && !isSyncing + let showBadge = count > 0 && !isSyncing && !(showMention && count == 1) if showBadge { let text: String @@ -598,12 +593,11 @@ final class ChatListCell: UICollectionViewCell { // Animate badge appear/disappear (Telegram: scale spring) animateBadgeTransition(view: badgeContainer, shouldShow: showBadge, wasVisible: &wasBadgeVisible) - // Mention badge - let showMention = dialog.hasMention && count > 0 && !isSyncing + // Mention badge (Telegram: tinted vector icon) if showMention { - mentionBadgeContainer.backgroundColor = dialog.isMuted ? mutedBadgeBg : accentBlue + mentionImageView.tintColor = dialog.isMuted ? mutedBadgeBg : accentBlue } - animateBadgeTransition(view: mentionBadgeContainer, shouldShow: showMention, wasVisible: &wasMentionBadgeVisible) + animateBadgeTransition(view: mentionImageView, shouldShow: showMention, wasVisible: &wasMentionBadgeVisible) } /// Telegram badge animation: appear = scale 0.0001→1.2 (0.2s) → 1.0 (0.12s settle); @@ -777,7 +771,7 @@ final class ChatListCell: UICollectionViewCell { mutedIconView.isHidden = true statusImageView.isHidden = true badgeContainer.isHidden = true - mentionBadgeContainer.isHidden = true + mentionImageView.isHidden = true pinnedIconView.isHidden = true onlineIndicator.isHidden = true contentView.backgroundColor = .clear @@ -788,7 +782,7 @@ final class ChatListCell: UICollectionViewCell { wasBadgeVisible = false wasMentionBadgeVisible = false badgeContainer.transform = .identity - mentionBadgeContainer.transform = .identity + mentionImageView.transform = .identity } // MARK: - Highlight diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift index 7d754d4..d52f84f 100644 --- a/Rosetta/Features/MainTabView.swift +++ b/Rosetta/Features/MainTabView.swift @@ -32,7 +32,9 @@ struct MainTabView: View { } var body: some View { + #if DEBUG let _ = PerformanceLogger.shared.track("mainTab.bodyEval") + #endif ZStack { Group { if #available(iOS 26.0, *) { diff --git a/Rosetta/Features/Settings/SettingsViewController.swift b/Rosetta/Features/Settings/SettingsViewController.swift index 4b02858..104d423 100644 --- a/Rosetta/Features/Settings/SettingsViewController.swift +++ b/Rosetta/Features/Settings/SettingsViewController.swift @@ -130,6 +130,7 @@ final class SettingsViewController: UIViewController, UIScrollViewDelegate { let darkModeView = AnyView( DarkModeButton() .glassCircle() + .frame(maxWidth: .infinity, alignment: .leading) ) let darkHosting = UIHostingController(rootView: darkModeView) darkHosting.view.backgroundColor = .clear @@ -143,6 +144,7 @@ final class SettingsViewController: UIViewController, UIScrollViewDelegate { SettingsEditButton { [weak self] in self?.editTapped() } + .frame(maxWidth: .infinity, alignment: .trailing) ) let editHost = UIHostingController(rootView: editView) editHost.view.backgroundColor = UIColor.clear @@ -235,8 +237,9 @@ final class SettingsViewController: UIViewController, UIScrollViewDelegate { let toolbarH: CGFloat = 44 toolbarView.frame = CGRect(x: 0, y: safeTop, width: w, height: toolbarH) - darkModeHosting?.view.frame = CGRect(x: hPad, y: 0, width: 44, height: 44) - editHosting?.view.frame = CGRect(x: w - hPad - 80, y: 0, width: 80, height: 44) + let toolbarBtnW: CGFloat = 100 + darkModeHosting?.view.frame = CGRect(x: hPad, y: 0, width: toolbarBtnW, height: 44) + editHosting?.view.frame = CGRect(x: w - hPad - toolbarBtnW, y: 0, width: toolbarBtnW, height: 44) // Content layout var y: CGFloat = safeTop + toolbarH + 15 diff --git a/Rosetta/Resources/Lottie/voice_anim_mic_to_video.json b/Rosetta/Resources/Lottie/voice_anim_mic_to_video.json new file mode 100644 index 0000000..fea54cb --- /dev/null +++ b/Rosetta/Resources/Lottie/voice_anim_mic_to_video.json @@ -0,0 +1 @@ +{"v":"5.8.1","fr":60,"ip":0,"op":15,"w":30,"h":30,"nm":"micToVideo","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Vector 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[15,25,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[10,10,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,2],[0,-2]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.4],"y":[0]},"t":0,"s":[0]},{"t":14,"s":[25]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.4],"y":[0]},"t":0,"s":[100]},{"t":14,"s":[25]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.66,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[1000,1000],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector 3","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":15,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Rectangle 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[15,15,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[10,10,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.4,"y":0},"t":0,"s":[{"i":[[0.532,-1.104],[0,-2.884],[-0.63,-1.057],[-0.829,-0.535],[-2.458,0],[-0.793,0.482],[-0.351,0.627],[0,2.43],[0.536,1.112],[1.104,0.532],[2.884,0],[1.112,-0.536]],"o":[[-0.536,1.112],[0,1.993],[0.63,1.057],[0.829,0.535],[2.458,0],[0.793,-0.482],[0.351,-0.627],[0,-2.884],[-0.532,-1.104],[-1.112,-0.536],[-2.884,0],[-1.104,0.532]],"v":[[-7.964,-5.938],[-8.5,-0.5],[-7.205,4.052],[-4.586,6.672],[0,8],[4.614,6.672],[7.19,4.052],[8.5,-0.5],[7.964,-5.938],[5.438,-8.464],[0,-9],[-5.438,-8.464]],"c":true}]},{"t":14,"s":[{"i":[[0.688,-1.429],[0,-3.732],[-0.693,-1.439],[-1.429,-0.688],[-3.732,0],[-1.439,0.693],[-0.688,1.429],[0,3.732],[0.693,1.439],[1.429,0.688],[3.732,0],[1.439,-0.693]],"o":[[-0.693,1.439],[0,3.732],[0.688,1.429],[1.439,0.693],[3.732,0],[1.429,-0.688],[0.693,-1.439],[0,-3.732],[-0.688,-1.429],[-1.439,-0.693],[-3.732,0],[-1.429,0.688]],"v":[[-10.307,-7.037],[-11,0],[-10.307,7.037],[-7.037,10.307],[0,11],[7.037,10.307],[10.307,7.037],[11,0],[10.307,-7.037],[7.037,-10.307],[0,-11],[-7.037,-10.307]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.4],"y":[0]},"t":0,"s":[25.4]},{"t":14,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.4],"y":[0]},"t":0,"s":[74.7]},{"t":14,"s":[100]}],"ix":2},"o":{"a":0,"k":-59,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.66,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[1000,1000],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 2","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":15,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Rectangle 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[15,15,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[10,10,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.4,"y":0},"t":0,"s":[{"i":[[2.485,0],[0,-2.485],[0,0],[-2.485,0],[0,2.485],[0,0]],"o":[[-2.485,0],[0,0],[0,2.485],[2.485,0],[0,0],[0,-2.485]],"v":[[0,-11],[-4.5,-6.5],[-4.5,-0.5],[0,4],[4.5,-0.5],[4.5,-6.5]],"c":true}]},{"t":14,"s":[{"i":[[2.761,0],[0,-2.761],[0,0],[-2.761,0],[0,2.761],[0,0]],"o":[[-2.761,0],[0,0],[0,2.761],[2.761,0],[0,0],[0,-2.761]],"v":[[0,-5],[-5,0],[-5,0.01],[0,5],[5,0.01],[5,0]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"rd","nm":"Round Corners 1","r":{"a":0,"k":5,"ix":1},"ix":2,"mn":"ADBE Vector Filter - RC","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.66,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[1000,1000],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":15,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/Rosetta/Resources/Lottie/voice_anim_playpause.json b/Rosetta/Resources/Lottie/voice_anim_playpause.json new file mode 100644 index 0000000..b924605 --- /dev/null +++ b/Rosetta/Resources/Lottie/voice_anim_playpause.json @@ -0,0 +1 @@ +{"v":"5.6.5","fr":60,"ip":0,"op":83,"w":320,"h":320,"nm":"Play/pause","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":5,"ty":4,"nm":"Path 37","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.722],"y":[1]},"o":{"x":[0.5],"y":[0]},"t":48,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.286],"y":[0]},"t":67,"s":[-90]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.365],"y":[0]},"t":74,"s":[-95]},{"t":80,"s":[-90]}],"ix":10},"p":{"a":0,"k":[160.5,160.125,0],"ix":2},"a":{"a":0,"k":[8,0.167,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":40,"s":[300,-300,100]},{"i":{"x":[0.611,0.611,0.667],"y":[0.94,1.06,1]},"o":{"x":[0.412,0.412,0.167],"y":[-0.244,0.244,0]},"t":46,"s":[290,-290,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.48,0.48,0.333],"y":[0.863,-0.863,0]},"t":55,"s":[320,-320,100]},{"t":68,"s":[300,-300,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":48,"s":[{"i":[[0,0],[-2.21,0],[0,0],[0,-2.21],[0,0],[2.21,0],[0,0],[0,2.21]],"o":[[0,-2.21],[0,0],[2.21,0],[0,0],[0,2.21],[0,0],[-2.21,0],[0,0]],"v":[[-11.5,-22],[-7.5,-26],[0.5,-26],[4.5,-22],[4.5,22],[0.5,26],[-7.5,26],[-11.5,22]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":54,"s":[{"i":[[0,0],[-2.717,-0.012],[-1.834,0.026],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.316,2.063]],"o":[[1.081,-2.999],[1.538,0.008],[1.823,-0.021],[0,0],[0,2.21],[0,0],[-2.552,0],[0.649,-1.662]],"v":[[-2.273,-21.505],[3.254,-24.941],[3.664,-24.951],[7.601,-21.241],[7.61,21.137],[3.61,25.137],[-7.94,25.154],[-11.561,20.66]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":55,"s":[{"i":[[0,0],[-2.797,-0.014],[-2.12,0.03],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.365,2.04]],"o":[[1.25,-3.123],[1.778,0.009],[1.762,-0.025],[0,0],[0,2.21],[0,0],[-2.605,0],[0.75,-1.921]],"v":[[-2.132,-21.447],[3.633,-24.795],[7,-24.815],[10.927,-21.15],[10.937,20.974],[6.937,24.974],[-8.25,24.973],[-11.813,20.402]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":58,"s":[{"i":[[-4.274,9.79],[-3.258,-0.034],[-1.795,0.001],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.688,1.889]],"o":[[1.456,-3.335],[1.81,0.019],[2.632,0.001],[0,0],[0,2.21],[0,0],[-2.955,0],[1.415,-3.623]],"v":[[0.18,-17.732],[5.758,-23.992],[7.066,-23.989],[10.962,-20.403],[10.948,20.05],[6.948,24.05],[-12.547,24.042],[-15.722,18.965]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":59,"s":[{"i":[[-4.659,9.537],[-2.427,0.018],[-1.618,0.014],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.787,1.842]],"o":[[1.633,-3.342],[1.62,-0.012],[2.673,-0.017],[0,0],[0,2.21],[0,0],[-2.97,0],[1.599,-3.576]],"v":[[-0.299,-14.603],[6.479,-23.844],[7.004,-23.853],[10.945,-20.35],[10.957,20.041],[6.957,24.041],[-13.536,24.032],[-16.655,18.961]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":60,"s":[{"i":[[-4.927,9.356],[-1.952,3.299],[-1.433,0.138],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.878,1.799]],"o":[[1.739,-3.3],[0.659,-1.113],[2.715,-0.313],[0,0],[0,2.21],[0,0],[-2.983,0],[1.767,-3.534]],"v":[[-1.291,-11.822],[4.056,-21.407],[6.933,-23.739],[10.94,-20.326],[10.951,20.022],[6.951,24.022],[-14.451,24.013],[-17.519,18.948]],"c":true}]},{"t":62,"s":[{"i":[[-5.281,9.116],[-1.964,3.326],[-1.188,0.301],[0,-2.21],[0,0],[4.467,-0.062],[0,0],[-0.997,1.742]],"o":[[1.879,-3.243],[1.139,-1.928],[2.77,-0.703],[0,0],[0,2.21],[0,0],[-3,0],[1.99,-3.478]],"v":[[-3,-8.127],[2.924,-18.197],[6.875,-23.572],[10.958,-20.26],[10.979,20.014],[6.979,24.014],[-15.625,24.005],[-18.625,18.946]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Path","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":40,"op":83,"st":69,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"Path 35","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.722],"y":[1]},"o":{"x":[0.5],"y":[0]},"t":48,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.286],"y":[0]},"t":67,"s":[-90]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.365],"y":[0]},"t":74,"s":[-95]},{"t":80,"s":[-90]}],"ix":10},"p":{"a":0,"k":[160.5,160.125,0],"ix":2},"a":{"a":0,"k":[8,0.167,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":40,"s":[-300,-300,100]},{"i":{"x":[0.611,0.611,0.667],"y":[1.06,1.06,1]},"o":{"x":[0.412,0.412,0.167],"y":[0.244,0.244,0]},"t":46,"s":[-290,-290,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.48,0.48,0.333],"y":[-0.863,-0.863,0]},"t":55,"s":[-320,-320,100]},{"t":68,"s":[-300,-300,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":48,"s":[{"i":[[0,0],[-2.21,0],[0,0],[0,-2.21],[0,0],[2.21,0],[0,0],[0,2.21]],"o":[[0,-2.21],[0,0],[2.21,0],[0,0],[0,2.21],[0,0],[-2.21,0],[0,0]],"v":[[-11.5,-22],[-7.5,-26],[0.5,-26],[4.5,-22],[4.5,22],[0.5,26],[-7.5,26],[-11.5,22]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":54,"s":[{"i":[[0,0],[-2.717,-0.012],[-1.834,0.026],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.316,2.063]],"o":[[1.081,-2.999],[1.538,0.008],[1.823,-0.021],[0,0],[0,2.21],[0,0],[-2.552,0],[0.649,-1.662]],"v":[[-2.273,-21.505],[3.254,-24.941],[3.664,-24.951],[7.601,-21.241],[7.61,21.137],[3.61,25.137],[-7.94,25.154],[-11.561,20.66]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":55,"s":[{"i":[[0,0],[-2.797,-0.014],[-2.12,0.03],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.365,2.04]],"o":[[1.25,-3.123],[1.778,0.009],[1.762,-0.025],[0,0],[0,2.21],[0,0],[-2.605,0],[0.75,-1.921]],"v":[[-2.132,-21.447],[3.633,-24.795],[7,-24.815],[10.927,-21.15],[10.937,20.974],[6.937,24.974],[-8.25,24.973],[-11.813,20.402]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":58,"s":[{"i":[[-4.274,9.79],[-3.258,-0.034],[-1.795,0.001],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.688,1.889]],"o":[[1.456,-3.335],[1.81,0.019],[2.632,0.001],[0,0],[0,2.21],[0,0],[-2.955,0],[1.415,-3.623]],"v":[[0.18,-17.732],[5.758,-23.992],[7.066,-23.989],[10.962,-20.403],[10.948,20.05],[6.948,24.05],[-12.547,24.042],[-15.722,18.965]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":59,"s":[{"i":[[-4.659,9.537],[-2.427,0.018],[-1.618,0.014],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.787,1.842]],"o":[[1.633,-3.342],[1.62,-0.012],[2.673,-0.017],[0,0],[0,2.21],[0,0],[-2.97,0],[1.599,-3.576]],"v":[[-0.299,-14.603],[6.479,-23.844],[7.004,-23.853],[10.945,-20.35],[10.957,20.041],[6.957,24.041],[-13.536,24.032],[-16.655,18.961]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":60,"s":[{"i":[[-4.927,9.356],[-1.952,3.299],[-1.433,0.138],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.878,1.799]],"o":[[1.739,-3.3],[0.659,-1.113],[2.715,-0.313],[0,0],[0,2.21],[0,0],[-2.983,0],[1.767,-3.534]],"v":[[-1.291,-11.822],[4.056,-21.407],[6.933,-23.739],[10.94,-20.326],[10.951,20.022],[6.951,24.022],[-14.451,24.013],[-17.519,18.948]],"c":true}]},{"t":62,"s":[{"i":[[-5.281,9.116],[-1.964,3.326],[-1.188,0.301],[0,-2.21],[0,0],[4.467,-0.062],[0,0],[-0.997,1.742]],"o":[[1.879,-3.243],[1.139,-1.928],[2.77,-0.703],[0,0],[0,2.21],[0,0],[-3,0],[1.99,-3.478]],"v":[[-3,-8.127],[2.924,-18.197],[6.875,-23.572],[10.958,-20.26],[10.979,20.014],[6.979,24.014],[-15.625,24.005],[-18.625,18.946]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Path","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":40,"op":83,"st":69,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Path 31","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":0,"s":[-90]},{"i":{"x":[0.683],"y":[1]},"o":{"x":[0.688],"y":[0]},"t":6,"s":[-95]},{"t":29,"s":[0]}],"ix":10},"p":{"a":0,"k":[160.5,160.125,0],"ix":2},"a":{"a":0,"k":[8,0.167,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.611,0.611,0.667],"y":[0.869,1.131,1]},"o":{"x":[0.224,0.225,0.333],"y":[-0.205,-0.099,0]},"t":12,"s":[300,-300,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.48,0.48,0.333],"y":[0.398,-0.398,0]},"t":25,"s":[320,-320,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.412,0.412,0.167],"y":[-0.488,0.488,0]},"t":34,"s":[290,-290,100]},{"t":40,"s":[300,-300,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":10,"s":[{"i":[[-5.281,9.116],[-1.964,3.326],[-1.188,0.301],[0,-2.21],[0,0],[4.002,-0.075],[0,0],[-0.997,1.742]],"o":[[1.879,-3.243],[1.139,-1.928],[2.77,-0.703],[0,0],[0,2.21],[0,0],[-3,0],[1.99,-3.478]],"v":[[-3,-8.127],[2.924,-18.197],[6.875,-23.572],[10.958,-20.26],[10.979,20.014],[6.979,24.014],[-15.625,24.005],[-18.625,18.946]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":12,"s":[{"i":[[-4.927,9.356],[-1.952,3.299],[-1.433,0.138],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.878,1.799]],"o":[[1.739,-3.3],[0.659,-1.113],[2.715,-0.313],[0,0],[0,2.21],[0,0],[-2.983,0],[1.767,-3.534]],"v":[[-1.291,-11.822],[4.056,-21.407],[6.933,-23.739],[10.94,-20.326],[10.951,20.022],[6.951,24.022],[-14.451,24.013],[-17.519,18.948]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":13,"s":[{"i":[[-4.659,9.537],[-2.427,0.018],[-1.618,0.014],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.787,1.842]],"o":[[1.633,-3.342],[1.62,-0.012],[2.673,-0.017],[0,0],[0,2.21],[0,0],[-2.97,0],[1.599,-3.576]],"v":[[-0.299,-14.603],[6.479,-23.844],[7.004,-23.853],[10.945,-20.35],[10.957,20.041],[6.957,24.041],[-13.536,24.032],[-16.655,18.961]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":14,"s":[{"i":[[-4.274,9.79],[-3.258,-0.034],[-1.795,0.001],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.688,1.889]],"o":[[1.456,-3.335],[1.81,0.019],[2.632,0.001],[0,0],[0,2.21],[0,0],[-2.955,0],[1.415,-3.623]],"v":[[0.18,-17.732],[5.758,-23.992],[7.066,-23.989],[10.962,-20.403],[10.948,20.05],[6.948,24.05],[-12.547,24.042],[-15.722,18.965]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":17,"s":[{"i":[[0,0],[-2.797,-0.014],[-2.12,0.03],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.365,2.04]],"o":[[1.25,-3.123],[1.778,0.009],[1.762,-0.025],[0,0],[0,2.21],[0,0],[-2.605,0],[0.75,-1.921]],"v":[[-2.132,-21.447],[3.633,-24.795],[7,-24.815],[10.927,-21.15],[10.937,20.974],[6.937,24.974],[-8.25,24.973],[-11.813,20.402]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":18,"s":[{"i":[[0,0],[-2.717,-0.012],[-1.834,0.026],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.316,2.063]],"o":[[1.081,-2.999],[1.538,0.008],[1.823,-0.021],[0,0],[0,2.21],[0,0],[-2.552,0],[0.649,-1.662]],"v":[[-2.273,-21.505],[3.254,-24.941],[3.664,-24.951],[7.601,-21.241],[7.61,21.137],[3.61,25.137],[-7.94,25.154],[-11.561,20.66]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[-2.21,0],[0,0],[0,-2.21],[0,0],[2.21,0],[0,0],[0,2.21]],"o":[[0,-2.21],[0,0],[2.21,0],[0,0],[0,2.21],[0,0],[-2.21,0],[0,0]],"v":[[-11.5,-22],[-7.5,-26],[0.5,-26],[4.5,-22],[4.5,22],[0.5,26],[-7.5,26],[-11.5,22]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Path","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":40,"st":30,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"Path 30","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":0,"s":[-90]},{"i":{"x":[0.683],"y":[1]},"o":{"x":[0.688],"y":[0]},"t":6,"s":[-95]},{"t":29,"s":[0]}],"ix":10},"p":{"a":0,"k":[160.5,160.125,0],"ix":2},"a":{"a":0,"k":[8,0.167,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.611,0.611,0.667],"y":[1.131,1.131,1]},"o":{"x":[0.531,0.531,0.333],"y":[0.241,0.241,0]},"t":12,"s":[-300,-300,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.48,0.48,0.333],"y":[-0.398,-0.398,0]},"t":25,"s":[-320,-320,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.412,0.412,0.167],"y":[0.488,0.488,0]},"t":34,"s":[-290,-290,100]},{"t":40,"s":[-300,-300,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":10,"s":[{"i":[[-5.281,9.116],[-1.964,3.326],[-1.188,0.301],[0,-2.21],[0,0],[4.002,-0.075],[0,0],[-0.997,1.742]],"o":[[1.879,-3.243],[1.139,-1.928],[2.77,-0.703],[0,0],[0,2.21],[0,0],[-3,0],[1.99,-3.478]],"v":[[-3,-8.127],[2.924,-18.197],[6.875,-23.572],[10.958,-20.26],[10.979,20.014],[6.979,24.014],[-15.625,24.005],[-18.625,18.946]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":12,"s":[{"i":[[-4.927,9.356],[-1.952,3.299],[-1.433,0.138],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.878,1.799]],"o":[[1.739,-3.3],[0.659,-1.113],[2.715,-0.313],[0,0],[0,2.21],[0,0],[-2.983,0],[1.767,-3.534]],"v":[[-1.291,-11.822],[4.056,-21.407],[6.933,-23.739],[10.94,-20.326],[10.951,20.022],[6.951,24.022],[-14.451,24.013],[-17.519,18.948]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":13,"s":[{"i":[[-4.659,9.537],[-2.427,0.018],[-1.618,0.014],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.787,1.842]],"o":[[1.633,-3.342],[1.62,-0.012],[2.673,-0.017],[0,0],[0,2.21],[0,0],[-2.97,0],[1.599,-3.576]],"v":[[-0.299,-14.603],[6.479,-23.844],[7.004,-23.853],[10.945,-20.35],[10.957,20.041],[6.957,24.041],[-13.536,24.032],[-16.655,18.961]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":14,"s":[{"i":[[-4.274,9.79],[-3.258,-0.034],[-1.795,0.001],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.688,1.889]],"o":[[1.456,-3.335],[1.81,0.019],[2.632,0.001],[0,0],[0,2.21],[0,0],[-2.955,0],[1.415,-3.623]],"v":[[0.18,-17.732],[5.758,-23.992],[7.066,-23.989],[10.962,-20.403],[10.948,20.05],[6.948,24.05],[-12.547,24.042],[-15.722,18.965]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":17,"s":[{"i":[[0,0],[-2.797,-0.014],[-2.12,0.03],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.365,2.04]],"o":[[1.25,-3.123],[1.778,0.009],[1.762,-0.025],[0,0],[0,2.21],[0,0],[-2.605,0],[0.75,-1.921]],"v":[[-2.132,-21.447],[3.633,-24.795],[7,-24.815],[10.927,-21.15],[10.937,20.974],[6.937,24.974],[-8.25,24.973],[-11.813,20.402]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":18,"s":[{"i":[[0,0],[-2.717,-0.012],[-1.834,0.026],[0,-2.21],[0,0],[2.21,0],[0,0],[-0.316,2.063]],"o":[[1.081,-2.999],[1.538,0.008],[1.823,-0.021],[0,0],[0,2.21],[0,0],[-2.552,0],[0.649,-1.662]],"v":[[-2.273,-21.505],[3.254,-24.941],[3.664,-24.951],[7.601,-21.241],[7.61,21.137],[3.61,25.137],[-7.94,25.154],[-11.561,20.66]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[-2.21,0],[0,0],[0,-2.21],[0,0],[2.21,0],[0,0],[0,2.21]],"o":[[0,-2.21],[0,0],[2.21,0],[0,0],[0,2.21],[0,0],[-2.21,0],[0,0]],"v":[[-11.5,-22],[-7.5,-26],[0.5,-26],[4.5,-22],[4.5,22],[0.5,26],[-7.5,26],[-11.5,22]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Path","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":40,"st":30,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/Rosetta/Resources/Lottie/voice_anim_video_to_mic.json b/Rosetta/Resources/Lottie/voice_anim_video_to_mic.json new file mode 100644 index 0000000..5e150a4 --- /dev/null +++ b/Rosetta/Resources/Lottie/voice_anim_video_to_mic.json @@ -0,0 +1 @@ +{"v":"5.8.1","fr":60,"ip":0,"op":15,"w":30,"h":30,"nm":"videoToMic","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Vector 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[15,25,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[10,10,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,2],[0,-2]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.4],"y":[0]},"t":0,"s":[25]},{"t":12,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.4],"y":[0]},"t":0,"s":[25]},{"t":12,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.66,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[1000,1000],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector 3","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":15,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Rectangle 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[15,15,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[10,10,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.4,"y":0},"t":0,"s":[{"i":[[0.688,-1.429],[0,-3.732],[-0.693,-1.439],[-1.429,-0.688],[-3.732,0],[-1.439,0.693],[-0.688,1.429],[0,3.732],[0.693,1.439],[1.429,0.688],[3.732,0],[1.439,-0.693]],"o":[[-0.693,1.439],[0,3.732],[0.688,1.429],[1.439,0.693],[3.732,0],[1.429,-0.688],[0.693,-1.439],[0,-3.732],[-0.688,-1.429],[-1.439,-0.693],[-3.732,0],[-1.429,0.688]],"v":[[-10.307,-7.037],[-11,0],[-10.307,7.037],[-7.037,10.307],[0,11],[7.037,10.307],[10.307,7.037],[11,0],[10.307,-7.037],[7.037,-10.307],[0,-11],[-7.037,-10.307]],"c":true}]},{"t":12,"s":[{"i":[[0.532,-1.104],[0,-2.884],[-0.63,-1.057],[-0.829,-0.535],[-2.458,0],[-0.793,0.482],[-0.351,0.627],[0,2.43],[0.536,1.112],[1.104,0.532],[2.884,0],[1.112,-0.536]],"o":[[-0.536,1.112],[0,1.993],[0.63,1.057],[0.829,0.535],[2.458,0],[0.793,-0.482],[0.351,-0.627],[0,-2.884],[-0.532,-1.104],[-1.112,-0.536],[-2.884,0],[-1.104,0.532]],"v":[[-7.964,-5.938],[-8.5,-0.5],[-7.205,4.052],[-4.586,6.672],[0,8],[4.614,6.672],[7.19,4.052],[8.5,-0.5],[7.964,-5.938],[5.438,-8.464],[0,-9],[-5.438,-8.464]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.4],"y":[0]},"t":0,"s":[0]},{"t":12,"s":[25.4]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.4],"y":[0]},"t":0,"s":[100]},{"t":12,"s":[74.7]}],"ix":2},"o":{"a":0,"k":-59,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.66,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[1000,1000],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 2","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":15,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Rectangle 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[15,15,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[10,10,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.4,"y":0},"t":0,"s":[{"i":[[2.761,0],[0,-2.761],[0,0],[-2.761,0],[0,2.761],[0,0]],"o":[[-2.761,0],[0,0],[0,2.761],[2.761,0],[0,0],[0,-2.761]],"v":[[0,-5],[-5,0],[-5,0.01],[0,5],[5,0.01],[5,0]],"c":true}]},{"t":12,"s":[{"i":[[2.485,0],[0,-2.485],[0,0],[-2.485,0],[0,2.485],[0,0]],"o":[[-2.485,0],[0,0],[0,2.485],[2.485,0],[0,0],[0,-2.485]],"v":[[0,-11],[-4.5,-6.5],[-4.5,-0.5],[0,4],[4.5,-0.5],[4.5,-6.5]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"rd","nm":"Round Corners 1","r":{"a":0,"k":5,"ix":1},"ix":2,"mn":"ADBE Vector Filter - RC","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.66,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[1000,1000],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":15,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/Rosetta/Resources/Lottie/voice_bin_blue.json b/Rosetta/Resources/Lottie/voice_bin_blue.json new file mode 100644 index 0000000..b385457 --- /dev/null +++ b/Rosetta/Resources/Lottie/voice_bin_blue.json @@ -0,0 +1 @@ +{"v":"5.6.5","fr":240,"ip":658,"op":1006,"w":240,"h":240,"nm":"Bin 2","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Cap11","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,-12,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-0.83,0],[0,0],[0,-0.83],[0,0],[0,0]],"o":[[0,0],[0,-0.83],[0,0],[0.83,0],[0,0],[0,0],[0,0]],"v":[[-3.5,1.5],[-3.5,0],[-2,-1.5],[2,-1.5],[3.5,0],[3.5,1.5],[3.5,1.5]],"c":false},"ix":2},"nm":"Контур 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.494117647059,0.898039215686,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Cap2","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":100,"ix":1},"e":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Обрезать контуры 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":658,"op":784,"st":-159.92,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Cap12","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":658.002,"s":[0]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":685.924,"s":[0]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":729.924,"s":[-5]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":730.002,"s":[-5]},{"t":753.001875,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.703,"y":1},"o":{"x":0.167,"y":0},"t":658.002,"s":[120,77.5,0],"to":[0,1.833,0],"ti":[0,0,0]},{"i":{"x":0.619,"y":0.858},"o":{"x":0.333,"y":0},"t":685.924,"s":[120,88.5,0],"to":[0,-5,0],"ti":[0,-1.108,0]},{"i":{"x":0.828,"y":0.836},"o":{"x":0.417,"y":0.063},"t":730.002,"s":[120,53.065,0],"to":[0,2.8,0],"ti":[0,5.785,0]},{"t":784.001875,"s":[120,157.496,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-8.5,0],[8.5,0]],"c":false},"ix":2},"nm":"Контур 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.494117647059,0.898039215686,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Cap1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":180,"ix":3},"m":1,"ix":2,"nm":"Обрезать контуры 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":658,"op":784,"st":-159.92,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Line13","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":758.002,"s":[139.5,129,0],"to":[0,1.167,0],"ti":[0,-1.167,0]},{"t":783.001875,"s":[139.5,136,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":658.002,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.25,-5.5],[-0.25,5.5]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":685.002,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.667,-3.833],[0.167,5.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":725.002,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.25,-5.5],[-0.25,5.5]],"c":false}]},{"t":783.001875,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.636,-3.957],[0.136,5.5]],"c":false}]}],"ix":2},"nm":"Контур 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.494117647059,0.898039215686,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.2,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Line3","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":750.002,"s":[0]},{"t":780.001875,"s":[93.974]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":750.002,"s":[100]},{"t":780.001875,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Обрезать контуры 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.494117647059,0.898039215686,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Заливка 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":658,"op":784,"st":-159.92,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Line14","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":758.002,"s":[120,129,0],"to":[0,1.167,0],"ti":[0,-1.167,0]},{"t":783.001875,"s":[120,136,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":658.002,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-5.5],[0,5.5]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":685.002,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-3.833],[0,5.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":733.002,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-5.5],[0,5.5]],"c":false}]},{"t":783.001875,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-3.957],[0,5.5]],"c":false}]}],"ix":2},"nm":"Контур 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.494117647059,0.898039215686,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.2,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Line2","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":750.002,"s":[0]},{"t":780.001875,"s":[93.974]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":750.002,"s":[100]},{"t":780.001875,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Обрезать контуры 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":3,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":658,"op":784,"st":-167.92,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Line15","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":758.002,"s":[100.5,129,0],"to":[0,1.167,0],"ti":[0,-1.167,0]},{"t":783.001875,"s":[100.5,136,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":657.002,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.25,-5.5],[0.25,5.5]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":685.002,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.667,-3.833],[-0.167,5.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":725.002,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.25,-5.5],[0.25,5.5]],"c":false}]},{"t":783.001875,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.636,-3.957],[-0.136,5.5]],"c":false}]}],"ix":2},"nm":"Контур 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.494117647059,0.898039215686,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.2,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Line1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":750.002,"s":[0]},{"t":780.001875,"s":[93.974]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":750.002,"s":[100]},{"t":780.001875,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Обрезать контуры 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":658,"op":784,"st":-159.92,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"Bin 5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[120.25,120,0],"ix":2},"a":{"a":0,"k":[0,-9,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":658.004,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7,-8.5],[-6.11,6.62],[-4.11,8.5],[4.11,8.5],[6.11,6.62],[7,-8.5]],"c":true}]},{"i":{"x":0.35,"y":1},"o":{"x":0.333,"y":0},"t":685.926,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7.833,-6.833],[-6.943,6.62],[-4.943,8.5],[4.943,8.5],[6.943,6.62],[7.833,-6.833]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.65,"y":0},"t":717.926,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7,-8.5],[-6.11,6.62],[-4.11,8.5],[4.11,8.5],[6.11,6.62],[7,-8.5]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":770.004,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7.833,-6.833],[-6.943,6.62],[-4.943,8.5],[4.943,8.5],[6.943,6.62],[7.833,-6.833]],"c":true}]},{"t":783.00375,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7.511,-7.479],[-6.621,6.62],[-4.621,8.5],[4.621,8.5],[6.621,6.62],[7.511,-7.479]],"c":true}]}],"ix":2},"nm":"Контур 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.494117647059,0.898039215686,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Bin","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":756.34,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":757.004,"s":[13.58]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":762.004,"s":[21.414]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":764.004,"s":[22.055]},{"t":783.00375,"s":[33.8]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":756.34,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":757.004,"s":[86.42]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":762.004,"s":[78.586]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":764.004,"s":[77.945]},{"t":783.00375,"s":[66.2]}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":757.004,"s":[-47]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":768.004,"s":[-49]},{"t":783.00375,"s":[-46]}],"ix":3},"m":1,"ix":2,"nm":"Обрезать контуры 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":658,"op":784,"st":-159.84,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Cap6","parent":8,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,-12,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-0.83,0],[0,0],[0,-0.83],[0,0],[0,0]],"o":[[0,0],[0,-0.83],[0,0],[0.83,0],[0,0],[0,0],[0,0]],"v":[[-3.5,1.5],[-3.5,0],[-2,-1.5],[2,-1.5],[3.5,0],[3.5,1.5],[3.5,1.5]],"c":false},"ix":2},"nm":"Контур 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.231372563979,0.188235309077,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Cap2","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":100,"ix":1},"e":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Обрезать контуры 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":783,"op":1006,"st":-30,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"Cap5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":819,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":847,"s":[-5]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":886,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":914,"s":[10]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":946,"s":[-12]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":966,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":986,"s":[-3]},{"t":1006,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.617,"y":0.933},"o":{"x":0.326,"y":0.353},"t":786,"s":[120,157.496,0],"to":[0,-27.373,0],"ti":[0,-1.275,0]},{"i":{"x":0.703,"y":1},"o":{"x":0.341,"y":0.112},"t":847,"s":[120,53.065,0],"to":[0,2.8,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":886,"s":[120,88.5,0],"to":[0,-5,0],"ti":[0,4.417,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":914,"s":[120,58.5,0],"to":[0,-4.417,0],"ti":[0,-4.083,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":946,"s":[120,62,0],"to":[0,4.083,0],"ti":[0,-0.667,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":966,"s":[120,83,0],"to":[0,0.667,0],"ti":[0,0.917,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":986,"s":[120,66,0],"to":[0,-0.917,0],"ti":[0,-1.917,0]},{"t":1006,"s":[120,77.5,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-8.5,0],[8.5,0]],"c":false},"ix":2},"nm":"Контур 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.231372563979,0.188235309077,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Cap1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":180,"ix":3},"m":1,"ix":2,"nm":"Обрезать контуры 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":783,"op":1006,"st":-30,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"Line6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":783,"s":[139.5,136,0],"to":[0,-1.167,0],"ti":[0,1.167,0]},{"t":808,"s":[139.5,129,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":783,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.579,-4.186],[0.079,5.5]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":793.664,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.667,-3.833],[0.167,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":845.664,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.25,-5.5],[-0.25,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":885.664,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.667,-3.833],[0.167,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":913.664,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.25,-5.5],[-0.25,5.5]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":929.664,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.25,-5.5],[-0.25,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":965.664,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.458,-5.083],[-0.042,5.5]],"c":false}]},{"t":989.6640625,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.25,-5.5],[-0.25,5.5]],"c":false}]}],"ix":2},"nm":"Контур 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.231372563979,0.188235309077,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.2,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Line3","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":783,"s":[100]},{"t":821,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":783,"s":[100]},{"t":821,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Обрезать контуры 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.231372563979,0.188235309077,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Заливка 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":783,"op":1006,"st":-31,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"Line5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":783,"s":[120,136,0],"to":[0,-1.167,0],"ti":[0,1.167,0]},{"t":808,"s":[120,129,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":783.336,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-4.186],[0,5.5]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":794,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-3.833],[0,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":838,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-5.5],[0,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":886,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-3.833],[0,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":914,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-5.5],[0,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":962,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-5.083],[0,5.5]],"c":false}]},{"t":990,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-5.5],[0,5.5]],"c":false}]}],"ix":2},"nm":"Контур 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.231372563979,0.188235309077,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.2,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Line2","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":783,"s":[100]},{"t":821,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":783,"s":[100]},{"t":821,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Обрезать контуры 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":783,"op":1006,"st":-38,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"Line4","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":782,"s":[0]},{"t":783,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":783,"s":[100.5,136,0],"to":[0,0,0],"ti":[0,0,0]},{"t":808,"s":[100.5,129,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":783.336,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.579,-4.186],[-0.079,5.5]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":794,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.667,-3.833],[-0.167,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":846,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.25,-5.5],[0.25,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":886,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.667,-3.833],[-0.167,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":914,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.25,-5.5],[0.25,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":930,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.25,-5.5],[0.25,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":966,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.458,-5.083],[0.042,5.5]],"c":false}]},{"t":990,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.25,-5.5],[0.25,5.5]],"c":false}]}],"ix":2},"nm":"Контур 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.231372563979,0.188235309077,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.2,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Line1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":783,"s":[100]},{"t":821,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":783,"s":[100]},{"t":821,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Обрезать контуры 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":783,"op":1006,"st":-30,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"Bin 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[120.25,120,0],"ix":2},"a":{"a":0,"k":[0,-9,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":783.336,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7.511,-7.479],[-6.621,6.62],[-4.621,8.5],[4.621,8.5],[6.621,6.62],[7.511,-7.479]],"c":true}]},{"i":{"x":0.35,"y":1},"o":{"x":0.333,"y":0},"t":802,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7.833,-6.833],[-6.943,6.62],[-4.943,8.5],[4.943,8.5],[6.943,6.62],[7.833,-6.833]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.65,"y":0},"t":854,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7,-8.5],[-6.11,6.62],[-4.11,8.5],[4.11,8.5],[6.11,6.62],[7,-8.5]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":886,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7.833,-6.833],[-6.943,6.62],[-4.943,8.5],[4.943,8.5],[6.943,6.62],[7.833,-6.833]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":922,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7,-8.5],[-6.11,6.62],[-4.11,8.5],[4.11,8.5],[6.11,6.62],[7,-8.5]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":938,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7,-8.5],[-6.11,6.62],[-4.11,8.5],[4.11,8.5],[6.11,6.62],[7,-8.5]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":966,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7.417,-7.667],[-6.527,6.62],[-4.527,8.5],[4.527,8.5],[6.527,6.62],[7.417,-7.667]],"c":true}]},{"t":982,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7,-8.5],[-6.11,6.62],[-4.11,8.5],[4.11,8.5],[6.11,6.62],[7,-8.5]],"c":true}]}],"ix":2},"nm":"Контур 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.231372563979,0.188235309077,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Bin","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":783,"s":[33.8]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":816,"s":[15.236]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":817.336,"s":[13.58]},{"t":818,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":783,"s":[66.2]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":816,"s":[84.764]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":817.336,"s":[86.42]},{"t":818,"s":[100]}],"ix":2},"o":{"a":0,"k":-48,"ix":3},"m":1,"ix":2,"nm":"Обрезать контуры 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":783,"op":1006,"st":-30,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/Rosetta/Resources/Lottie/voice_bin_red.json b/Rosetta/Resources/Lottie/voice_bin_red.json new file mode 100644 index 0000000..f1babaf --- /dev/null +++ b/Rosetta/Resources/Lottie/voice_bin_red.json @@ -0,0 +1 @@ +{"v":"5.6.5","fr":360,"ip":1026,"op":1508,"w":240,"h":240,"nm":"RedBin","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Cap4","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":1188,"s":[0,-3,0],"to":[0,-1.5,0],"ti":[0,1.5,0]},{"t":1230,"s":[0,-12,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-0.83,0],[0,0],[0,-0.83],[0,0],[0,0]],"o":[[0,0],[0,-0.83],[0,0],[0.83,0],[0,0],[0,0],[0,0]],"v":[[-3.5,1.5],[-3.5,0],[-2,-1.5],[2,-1.5],[3.5,0],[3.5,1.5],[3.5,1.5]],"c":false},"ix":2},"nm":"Контур 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.231372563979,0.188235309077,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Cap2","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1182,"s":[50]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1188,"s":[87.5]},{"t":1218,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1182,"s":[50]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1188,"s":[12.5]},{"t":1218,"s":[0]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Обрезать контуры 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":-48,"op":3552,"st":-48,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Cap3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":1225.5,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":1267.5,"s":[-5]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1326,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1368,"s":[10]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1416,"s":[-12]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1446,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1476,"s":[-3]},{"t":1506,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.617,"y":0.93},"o":{"x":0.333,"y":0.329},"t":1164,"s":[120,166.791,0],"to":[0,0,0],"ti":[0,-1.544,0]},{"i":{"x":0.703,"y":1},"o":{"x":0.341,"y":0.112},"t":1267.5,"s":[120,53.065,0],"to":[0,2.8,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1326,"s":[120,88.5,0],"to":[0,-5,0],"ti":[0,4.417,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1368,"s":[120,58.5,0],"to":[0,-4.417,0],"ti":[0,-4.083,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1416,"s":[120,62,0],"to":[0,4.083,0],"ti":[0,-0.667,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1446,"s":[120,83,0],"to":[0,0.667,0],"ti":[0,0.917,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1476,"s":[120,66,0],"to":[0,-0.917,0],"ti":[0,-1.917,0]},{"t":1506,"s":[120,77.5,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-8.5,0],[8.5,0]],"c":false},"ix":2},"nm":"Контур 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.231372563979,0.188235309077,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Cap1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1170,"s":[50]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1171.5,"s":[42.5]},{"t":1191,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1170,"s":[50]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1171.5,"s":[57.5]},{"t":1191,"s":[100]}],"ix":2},"o":{"a":0,"k":180,"ix":3},"m":1,"ix":2,"nm":"Обрезать контуры 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":-48,"op":3552,"st":-48,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Line3","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1170,"s":[0]},{"t":1171.5,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":1171.5,"s":[139.5,136,0],"to":[0,-1.167,0],"ti":[0,1.167,0]},{"t":1209,"s":[139.5,129,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1134,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.25,-5.5],[-0.25,5.5]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":1188,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.667,-3.833],[0.167,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1266,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.25,-5.5],[-0.25,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1326,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.667,-3.833],[0.167,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1368,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.25,-5.5],[-0.25,5.5]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":1392,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.25,-5.5],[-0.25,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":1446,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.458,-5.083],[-0.042,5.5]],"c":false}]},{"t":1482,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.25,-5.5],[-0.25,5.5]],"c":false}]}],"ix":2},"nm":"Контур 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.231372563979,0.188235309077,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.2,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Line3","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1177,"s":[100]},{"t":1231.5,"s":[0]}],"ix":1,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Line1').content('\\u041e\\u0431\\u0440\\u0435\\u0437\\u0430\\u0442\\u044c \\u043a\\u043e\\u043d\\u0442\\u0443\\u0440\\u044b 1').start;"},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1177,"s":[100]},{"t":1231.5,"s":[100]}],"ix":2,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Line1').content('\\u041e\\u0431\\u0440\\u0435\\u0437\\u0430\\u0442\\u044c \\u043a\\u043e\\u043d\\u0442\\u0443\\u0440\\u044b 1').end;"},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Обрезать контуры 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.231372563979,0.188235309077,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Заливка 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":-48,"op":3552,"st":-48,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Line2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1170,"s":[0]},{"t":1171.5,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":1171.5,"s":[120,136,0],"to":[0,-1.167,0],"ti":[0,1.167,0]},{"t":1209,"s":[120,129,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1134,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-5.5],[0,5.5]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":1188,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-3.833],[0,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1254,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-5.5],[0,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1326,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-3.833],[0,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1368,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-5.5],[0,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1440,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-5.083],[0,5.5]],"c":false}]},{"t":1482,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-5.5],[0,5.5]],"c":false}]}],"ix":2},"nm":"Контур 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.231372563979,0.188235309077,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.2,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Line2","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1177,"s":[100]},{"t":1231.5,"s":[0]}],"ix":1,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Line1').content('\\u041e\\u0431\\u0440\\u0435\\u0437\\u0430\\u0442\\u044c \\u043a\\u043e\\u043d\\u0442\\u0443\\u0440\\u044b 1').start;"},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1177,"s":[100]},{"t":1231.5,"s":[100]}],"ix":2,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Line1').content('\\u041e\\u0431\\u0440\\u0435\\u0437\\u0430\\u0442\\u044c \\u043a\\u043e\\u043d\\u0442\\u0443\\u0440\\u044b 1').end;"},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Обрезать контуры 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":-60,"op":3540,"st":-60,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Line1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1170,"s":[0]},{"t":1171.5,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":1171.5,"s":[100.5,136,0],"to":[0,0,0],"ti":[0,0,0]},{"t":1209,"s":[100.5,129,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1134,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.25,-5.5],[0.25,5.5]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":1188,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.667,-3.833],[-0.167,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1266,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.25,-5.5],[0.25,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1326,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.667,-3.833],[-0.167,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1368,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.25,-5.5],[0.25,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1392,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.25,-5.5],[0.25,5.5]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1446,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.458,-5.083],[0.042,5.5]],"c":false}]},{"t":1482,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.25,-5.5],[0.25,5.5]],"c":false}]}],"ix":2},"nm":"Контур 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.231372563979,0.188235309077,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.2,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Line1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1177,"s":[100]},{"t":1231.5,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1177,"s":[100]},{"t":1231.5,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Обрезать контуры 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":-48,"op":3552,"st":-48,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"Bin","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1143,"s":[0]},{"t":1146,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[120.25,120,0],"ix":2},"a":{"a":0,"k":[0,-9,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1134,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7,-8.5],[-6.11,6.62],[-4.11,8.5],[4.11,8.5],[6.11,6.62],[7,-8.5]],"c":true}]},{"i":{"x":0.35,"y":1},"o":{"x":0.333,"y":0},"t":1200,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7.833,-6.833],[-6.943,6.62],[-4.943,8.5],[4.943,8.5],[6.943,6.62],[7.833,-6.833]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.65,"y":0},"t":1278,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7,-8.5],[-6.11,6.62],[-4.11,8.5],[4.11,8.5],[6.11,6.62],[7,-8.5]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1326,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7.833,-6.833],[-6.943,6.62],[-4.943,8.5],[4.943,8.5],[6.943,6.62],[7.833,-6.833]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1380,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7,-8.5],[-6.11,6.62],[-4.11,8.5],[4.11,8.5],[6.11,6.62],[7,-8.5]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1404,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7,-8.5],[-6.11,6.62],[-4.11,8.5],[4.11,8.5],[6.11,6.62],[7,-8.5]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1446,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7.417,-7.667],[-6.527,6.62],[-4.527,8.5],[4.527,8.5],[6.527,6.62],[7.417,-7.667]],"c":true}]},{"t":1470,"s":[{"i":[[0,0],[0,0],[-1.06,0],[0,0],[-0.06,1.05],[0,0]],"o":[[0,0],[0.06,1.05],[0,0],[1.06,0],[0,0],[0,0]],"v":[[-7,-8.5],[-6.11,6.62],[-4.11,8.5],[4.11,8.5],[6.11,6.62],[7,-8.5]],"c":true}]}],"ix":2},"nm":"Контур 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.231372563979,0.188235309077,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Bin","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1137,"s":[50]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1221,"s":[15.236]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1223,"s":[13.58]},{"t":1224,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1137,"s":[50]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1221,"s":[84.764]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1223,"s":[86.42]},{"t":1224,"s":[100]}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1137,"s":[-46]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1149,"s":[-44.381]},{"t":1200,"s":[-48]}],"ix":3},"m":1,"ix":2,"nm":"Обрезать контуры 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":-42,"op":3552,"st":-48,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Recording","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.6,"y":0.999},"o":{"x":0.6,"y":0},"t":1026,"s":[120,120,0],"to":[0,-8,0],"ti":[0,-5.661,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.4,"y":0},"t":1092,"s":[120,72,0],"to":[0,5.661,0],"ti":[0,-13.661,0]},{"t":1152,"s":[120,153.969,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":1050,"s":[{"i":[[-2.76,0],[0,2.76],[2.76,0],[0,-2.76]],"o":[[2.76,0],[0,-2.76],[-2.76,0],[0,2.76]],"v":[[0,5],[5,0],[0,-5],[-5,0]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":1098,"s":[{"i":[[-2.76,0],[0,2.76],[2.76,0],[0,-2.76]],"o":[[2.76,0],[0,-2.76],[-2.76,0],[0,2.76]],"v":[[0,5],[5,0],[0,-5],[-5,0]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":1146,"s":[{"i":[[-2.57,0.001],[0.007,1.024],[2.738,0.018],[-0.007,-1.006]],"o":[[2.719,-0.001],[0.007,-0.979],[-2.579,0.027],[-0.002,1.028]],"v":[[-0.004,5.766],[3.211,4.612],[-0.004,3.479],[-3.317,4.612]],"c":true}]},{"t":1152,"s":[{"i":[[-2.547,0.001],[0.008,0.807],[2.735,0.02],[-0.007,-0.787]],"o":[[2.714,-0.001],[0.008,-0.756],[-2.557,0.031],[-0.002,0.812]],"v":[[-0.003,4.99],[4.177,4.323],[-0.003,3.667],[-4.271,4.323]],"c":true}]}],"ix":2},"nm":"Контур 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.231372997165,0.188234999776,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Заливка 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0.044,0.036],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Recording","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1320,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/Rosetta/Resources/Lottie/voice_lock.json b/Rosetta/Resources/Lottie/voice_lock.json new file mode 100644 index 0000000..24dfabb --- /dev/null +++ b/Rosetta/Resources/Lottie/voice_lock.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":30,"w":240,"h":360,"nm":"Lock2","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Rectangle","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":13,"s":[0]},{"t":23,"s":[90]}],"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[120]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":13,"s":[120]},{"t":23,"s":[120]}],"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[150]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":13,"s":[200]},{"t":23,"s":[180]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":13,"s":[{"i":[[-1.66,0],[0,0],[0,-1.66],[0,0],[1.66,0],[0,0],[0,1.66],[0,0]],"o":[[0,0],[1.66,0],[0,0],[0,1.66],[0,0],[-1.66,0],[0,0],[0,-1.66]],"v":[[-5,-7],[5,-7],[8,-4],[8,4],[5,7],[-5,7],[-8,4],[-8,-4]],"c":true}]},{"t":23,"s":[{"i":[[-1.66,0],[0,0],[0,-1.66],[0,0],[1.66,0],[0,0],[0,1.66],[0,0]],"o":[[0,0],[1.66,0],[0,0],[0,1.66],[0,0],[-1.66,0],[0,0],[0,-1.66]],"v":[[-5,-8],[5,-8],[8,-5],[8,5],[5,8],[-5,8],[-8,5],[-8,-5]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.494117647409,0.898039221764,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":13,"s":[{"i":[[0,0],[0,-1.66],[0,0],[-1.66,0],[0,0],[0,1.66],[0,0],[1.66,0]],"o":[[-1.66,0],[0,0],[0,1.66],[0,0],[1.66,0],[0,0],[0,-1.66],[0,0]],"v":[[-5,-7],[-8,-4],[-8,4],[-5,7],[5,7],[8,4],[8,-4],[5,-7]],"c":true}]},{"t":23,"s":[{"i":[[0,0],[0,-1.66],[0,0],[-1.66,0],[0,0],[0,1.66],[0,0],[1.66,0]],"o":[[-1.66,0],[0,0],[0,1.66],[0,0],[1.66,0],[0,0],[0,-1.66],[0,0]],"v":[[-5,-8],[-8,-5],[-8,5],[-5,8],[5,8],[8,5],[8,-5],[5,-8]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":10,"s":[97,97]},{"t":20,"s":[0,0]}],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.494117647409,0.898039221764,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Заливка 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":0,"op":30,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Path","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[10]},{"t":10,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":1,"y":0},"t":0,"s":[30,-32,0],"to":[0.015,12,0],"ti":[-0.012,-7.718,0]},{"t":15,"s":[30.088,39.998,0]}],"ix":2,"l":2},"a":{"a":0,"k":[30,36,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-2.76,0],[0,-2.76],[0,0]],"o":[[0,0],[0,-2.76],[2.76,0],[0,0],[0,0]],"v":[[-5,2],[-5,-1],[0,-6],[5,-1],[5,6]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.494117647409,0.898039221764,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Path","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[7]},{"t":15,"s":[50]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[-1.809]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[90]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0.214]},"t":6,"s":[88.872]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[79]},{"t":13,"s":[50]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":13,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Path 4","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.498,"y":1},"o":{"x":0.921,"y":0},"t":0,"s":[0,132,0],"to":[-0.018,-13.122,0],"ti":[0.001,0.644,0]},{"t":16,"s":[-0.62,8.043,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":1,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-8,3],[0,-3],[8,3]],"c":false}]},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-0.753,3],[0,-3],[0.753,3]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.494117647409,0.898039221764,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Path 4","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":5,"s":[0]},{"t":12,"s":[50]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":5,"s":[100]},{"t":12,"s":[50]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":30,"st":0,"ct":1,"bm":0}],"markers":[],"props":{}} \ No newline at end of file diff --git a/Rosetta/Resources/Lottie/voice_lock_pause.json b/Rosetta/Resources/Lottie/voice_lock_pause.json new file mode 100644 index 0000000..0ea030c --- /dev/null +++ b/Rosetta/Resources/Lottie/voice_lock_pause.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":60,"w":240,"h":360,"nm":"nLock3","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Rectangle 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":26,"s":[0]},{"t":46,"s":[90]}],"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":13,"s":[120]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":26,"s":[120]},{"t":46,"s":[120]}],"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":13,"s":[150]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":26,"s":[200]},{"t":46,"s":[180]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":26,"s":[{"i":[[-1.66,0],[0,0],[0,-1.66],[0,0],[1.66,0],[0,0],[0,1.66],[0,0]],"o":[[0,0],[1.66,0],[0,0],[0,1.66],[0,0],[-1.66,0],[0,0],[0,-1.66]],"v":[[-5,-7],[5,-7],[8,-4],[8,4],[5,7],[-5,7],[-8,4],[-8,-4]],"c":true}]},{"t":46,"s":[{"i":[[-1.66,0],[0,0],[0,-1.66],[0,0],[1.66,0],[0,0],[0,1.66],[0,0]],"o":[[0,0],[1.66,0],[0,0],[0,1.66],[0,0],[-1.66,0],[0,0],[0,-1.66]],"v":[[-6.25,-5.75],[6.25,-5.75],[9.25,-2.75],[9.25,2.75],[6.25,5.75],[-6.25,5.75],[-9.25,2.75],[-9.25,-2.75]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]},"t":26,"s":[0]},{"t":46,"s":[42]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]},"t":26,"s":[100]},{"t":46,"s":[63]}],"ix":2},"o":{"a":0,"k":212,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.494117647409,0.898039221764,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]},"t":26,"s":[1.33]},{"t":46,"s":[6]}],"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":26,"op":60,"st":13,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Rectangle 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":26,"s":[0]},{"t":46,"s":[90]}],"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":13,"s":[120]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":26,"s":[120]},{"t":46,"s":[120]}],"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":13,"s":[150]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":26,"s":[200]},{"t":46,"s":[180]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":26,"s":[{"i":[[-1.66,0],[0,0],[0,-1.66],[0,0],[1.66,0],[0,0],[0,1.66],[0,0]],"o":[[0,0],[1.66,0],[0,0],[0,1.66],[0,0],[-1.66,0],[0,0],[0,-1.66]],"v":[[-5,-7],[5,-7],[8,-4],[8,4],[5,7],[-5,7],[-8,4],[-8,-4]],"c":true}]},{"t":46,"s":[{"i":[[-1.66,0],[0,0],[0,-1.66],[0,0],[1.66,0],[0,0],[0,1.66],[0,0]],"o":[[0,0],[1.66,0],[0,0],[0,1.66],[0,0],[-1.66,0],[0,0],[0,-1.66]],"v":[[-6.25,-5.75],[6.25,-5.75],[9.25,-2.75],[9.25,2.75],[6.25,5.75],[-6.25,5.75],[-9.25,2.75],[-9.25,-2.75]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]},"t":26,"s":[0]},{"t":46,"s":[42]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]},"t":26,"s":[100]},{"t":46,"s":[63]}],"ix":2},"o":{"a":0,"k":32,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.494117647409,0.898039221764,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]},"t":26,"s":[1.33]},{"t":46,"s":[6]}],"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":26,"op":60,"st":13,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Rectangle","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":26,"s":[0]},{"t":46,"s":[90]}],"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[120]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":26,"s":[120]},{"t":46,"s":[120]}],"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[150]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":26,"s":[200]},{"t":46,"s":[180]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":26,"s":[{"i":[[-1.66,0],[0,0],[0,-1.66],[0,0],[1.66,0],[0,0],[0,1.66],[0,0]],"o":[[0,0],[1.66,0],[0,0],[0,1.66],[0,0],[-1.66,0],[0,0],[0,-1.66]],"v":[[-5,-7],[5,-7],[8,-4],[8,4],[5,7],[-5,7],[-8,4],[-8,-4]],"c":true}]},{"t":46,"s":[{"i":[[-1.66,0],[0,0],[0,-1.66],[0,0],[1.66,0],[0,0],[0,1.66],[0,0]],"o":[[0,0],[1.66,0],[0,0],[0,1.66],[0,0],[-1.66,0],[0,0],[0,-1.66]],"v":[[-5,-8],[5,-8],[8,-5],[8,5],[5,8],[-5,8],[-8,5],[-8,-5]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.494117647409,0.898039221764,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.494117647409,0.898039221764,1],"ix":4},"o":{"a":0,"k":0,"ix":5},"r":1,"bm":0,"nm":"Заливка 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":0,"op":26,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Path","parent":3,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[10]},{"t":10,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.899,"y":1},"o":{"x":0.865,"y":0},"t":0,"s":[30,-32,0],"to":[0.011,8.859,0],"ti":[0,0,0]},{"i":{"x":0.619,"y":0.999},"o":{"x":0.298,"y":0.002},"t":25,"s":[30.07,26.297,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.515,"y":1},"o":{"x":0.218,"y":0},"t":26,"s":[30.079,29.902,0],"to":[0,0,0],"ti":[-0.001,-0.956,0]},{"t":30,"s":[30.088,39.998,0]}],"ix":2,"l":2},"a":{"a":0,"k":[30,36,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-60.211,-43.124],[-59.526,27.443],[60.474,27.443],[59.789,-43.124]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.6,"y":0},"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-60.211,-43.124],[-60.177,23.75],[59.823,23.75],[59.789,-43.124]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-60.211,-43.124],[-60.177,21.088],[59.823,21.088],[59.789,-43.124]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.167,"y":0.167},"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-60.211,-43.124],[-60.177,-0.821],[59.823,-0.821],[59.789,-43.124]],"c":true}]},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-60.211,-43.124],[-60.177,-35],[59.823,-35],[59.789,-43.124]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-2.76,0],[0,-2.76],[0,0]],"o":[[0,0],[0,-2.76],[2.76,0],[0,0],[0,0]],"v":[[-5,2],[-5,-1],[0,-6],[5,-1],[5,6]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.494117647409,0.898039221764,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Path","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":28,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Path 4","parent":3,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.498,"y":1},"o":{"x":0.921,"y":0},"t":0,"s":[0,132,0],"to":[-0.018,-13.122,0],"ti":[0.001,0.644,0]},{"t":32,"s":[-0.62,8.043,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-55,-53],[-55,66],[60,66],[60,-53]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-55,-28],[-55,66],[60,66],[60,-28]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-55,-13],[-55,66],[60,66],[60,-13]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-55,12],[-55,66],[60,66],[60,12]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-55,19.5],[-55,66],[60,66],[60,19.5]],"c":true}]},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-55,25],[-55,66],[60,66],[60,25]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":1,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-8,3],[0,-3],[8,3]],"c":false}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-0.753,3],[0,-3],[0.753,3]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.494117647409,0.898039221764,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Path 5","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":1,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-8,3],[0,-3],[8,3]],"c":false}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-0.753,3],[0,-3],[0.753,3]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":21,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":22,"s":[60]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":23,"s":[80]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":24,"s":[94]},{"t":25,"s":[100]}],"ix":1},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.494117647409,0.898039221764,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Path 4","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":28,"st":0,"ct":1,"bm":0}],"markers":[],"props":{}} \ No newline at end of file diff --git a/Rosetta/Resources/Lottie/voice_lock_wait.json b/Rosetta/Resources/Lottie/voice_lock_wait.json new file mode 100644 index 0000000..7462cc0 --- /dev/null +++ b/Rosetta/Resources/Lottie/voice_lock_wait.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":120,"w":240,"h":360,"nm":"Lock1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Path 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[120,282,0],"to":[0,-1.667,0],"ti":[0,0.833,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":45,"s":[120,272,0],"to":[0,-0.833,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":59,"s":[120,277,0],"to":[0,0,0],"ti":[0,-0.833,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":75,"s":[120,272,0],"to":[0,0.833,0],"ti":[0,-1.667,0]},{"t":120,"s":[120,282,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-8,3],[0,-3],[8,3]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.592000007629,0.592000007629,0.592000007629,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Path 4","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Rectangle 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[120,150,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.66,0],[0,0],[0,-1.66],[0,0],[1.66,0],[0,0],[0,1.66],[0,0]],"o":[[0,0],[1.66,0],[0,0],[0,1.66],[0,0],[-1.66,0],[0,0],[0,-1.66]],"v":[[-5,-7],[5,-7],[8,-4],[8,4],[5,7],[-5,7],[-8,4],[-8,-4]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.592000007629,0.592000007629,0.592000007629,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.96862745285,0.96862745285,0.96862745285,1],"ix":4},"o":{"a":0,"k":0,"ix":5},"r":1,"bm":0,"nm":"Заливка 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":0,"op":120,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Rectangle","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[120,150,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.66,0],[0,0],[0,-1.66],[0,0],[1.66,0],[0,0],[0,1.66],[0,0]],"o":[[0,0],[1.66,0],[0,0],[0,1.66],[0,0],[-1.66,0],[0,0],[0,-1.66]],"v":[[-5,-7],[5,-7],[8,-4],[8,4],[5,7],[-5,7],[-8,4],[-8,-4]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.592000007629,0.592000007629,0.592000007629,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.96862745285,0.96862745285,0.96862745285,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Заливка 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":0,"op":120,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Path","tt":2,"tp":3,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[10]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":45,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":60,"s":[5]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":75,"s":[0]},{"t":120,"s":[10]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[150,118,0],"to":[0,1.667,0],"ti":[0,-0.833,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":45,"s":[150,128,0],"to":[0,0.833,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":60,"s":[150,123,0],"to":[0,0,0],"ti":[0,0.833,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":75,"s":[150,128,0],"to":[0,-0.833,0],"ti":[0,1.667,0]},{"t":120,"s":[150,118,0]}],"ix":2,"l":2},"a":{"a":0,"k":[30,36,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-2.76,0],[0,-2.76],[0,0]],"o":[[0,0],[0,-2.76],[2.76,0],[0,0],[0,0]],"v":[[-5,2],[-5,-1],[0,-6],[5,-1],[5,6]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.592000007629,0.592000007629,0.592000007629,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.33,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Обводка 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[600,600],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Path","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120,"st":0,"ct":1,"bm":0}],"markers":[],"props":{}} \ No newline at end of file diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 2d9595a..bec78d3 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -56,6 +56,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { + if UITestLaunchConfiguration.isVoiceRecordingFixtureEnabled { + return true + } + FirebaseApp.configure() // Set delegates @@ -793,6 +797,10 @@ struct RosettaApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate init() { + if UITestLaunchConfiguration.isVoiceRecordingFixtureEnabled { + return + } + UIWindow.appearance().backgroundColor = .systemBackground // Detect fresh install: UserDefaults are wiped on uninstall, Keychain is not. @@ -819,39 +827,45 @@ struct RosettaApp: App { var body: some Scene { WindowGroup { - DarkModeWrapper { - ZStack { - RosettaColors.Adaptive.background - .ignoresSafeArea() + if UITestLaunchConfiguration.isVoiceRecordingFixtureEnabled { + VoiceRecordingUITestFixtureView( + modeRawValue: UITestLaunchConfiguration.voiceRecordingFixtureMode + ) + } else { + DarkModeWrapper { + ZStack { + RosettaColors.Adaptive.background + .ignoresSafeArea() - if let appState { - rootView(for: appState) + if let appState { + rootView(for: appState) + } + + // Fade-through-black overlay for smooth screen transitions. + // Avoids UIKit-hosted Lottie views fighting SwiftUI opacity transitions. + Color.black + .ignoresSafeArea() + .opacity(transitionOverlay ? 1 : 0) + .allowsHitTesting(transitionOverlay) + .animation(.easeInOut(duration: 0.035), value: transitionOverlay) + + // Telegram parity: in-app notification banner. + inAppBannerOverlay } - - // Fade-through-black overlay for smooth screen transitions. - // Avoids UIKit-hosted Lottie views fighting SwiftUI opacity transitions. - Color.black - .ignoresSafeArea() - .opacity(transitionOverlay ? 1 : 0) - .allowsHitTesting(transitionOverlay) - .animation(.easeInOut(duration: 0.035), value: transitionOverlay) - - // Telegram parity: in-app notification banner. - inAppBannerOverlay } - } - // NOTE: preferredColorScheme removed — DarkModeWrapper is the single - // source of truth via window.overrideUserInterfaceStyle. Having both - // caused snapshot races where the hosting controller's stale - // preferredColorScheme(.dark) blocked the window's .light override, - // making dark→light circular reveal animation invisible. - .onAppear { - if appState == nil { - appState = initialState() + // NOTE: preferredColorScheme removed — DarkModeWrapper is the single + // source of truth via window.overrideUserInterfaceStyle. Having both + // caused snapshot races where the hosting controller's stale + // preferredColorScheme(.dark) blocked the window's .light override, + // making dark→light circular reveal animation invisible. + .onAppear { + if appState == nil { + appState = initialState() + } + } + .onOpenURL { url in + handleDeepLink(url) } - } - .onOpenURL { url in - handleDeepLink(url) } } } diff --git a/RosettaTests/SlidingWindowTests.swift b/RosettaTests/SlidingWindowTests.swift new file mode 100644 index 0000000..cc17e97 --- /dev/null +++ b/RosettaTests/SlidingWindowTests.swift @@ -0,0 +1,315 @@ +import XCTest +@testable import Rosetta + +/// Tests for MessageRepository sliding window: soft cache limits, bidirectional pagination, +/// and batch re-decryption. Ensures cache doesn't grow unbounded during scroll and that +/// pagination trim removes messages from the correct edge. +@MainActor +final class SlidingWindowTests: XCTestCase { + private var ctx: DBTestContext! + private let opponent = "02peer_sliding_window_test" + + override func setUpWithError() throws { + ctx = DBTestContext() + } + + override func tearDownWithError() throws { + ctx.teardown() + ctx = nil + } + + // MARK: - Helpers + + /// Inserts `count` incoming messages with sequential timestamps into DB + cache. + private func insertMessages(count: Int, startTimestamp: Int64 = 1000) async throws { + try await ctx.bootstrap() + + var events: [FixtureEvent] = [] + for i in 0.. 3 * 200 = 600 messages in cache + let totalMessages = 700 + try await insertMessages(count: totalMessages) + + // Reset and reload latest 200 + MessageRepository.shared.reloadLatest(for: opponent) + let initial = MessageRepository.shared.messages(for: opponent) + XCTAssertEqual(initial.count, MessageRepository.maxCacheSize) + + // Paginate up multiple times to grow cache beyond 3× maxCacheSize + var loadCount = 0 + while loadCount < 15 { + guard let earliest = MessageRepository.shared.messages(for: opponent).first else { break } + let older = MessageRepository.shared.loadOlderMessages( + for: opponent, + beforeTimestamp: earliest.timestamp, + beforeMessageId: earliest.id, + limit: MessageRepository.pageSize + ) + if older.isEmpty { break } + loadCount += 1 + } + + let finalCache = MessageRepository.shared.messages(for: opponent) + let hardLimit = MessageRepository.maxCacheSize * 3 + + XCTAssertLessThanOrEqual(finalCache.count, hardLimit, + "Cache must not exceed 3× maxCacheSize (\(hardLimit)), got \(finalCache.count)") + } + + /// loadOlderMessages does NOT trim when cache is within 3× maxCacheSize. + func testLoadOlderDoesNotTrimBelowThreshold() async throws { + try await insertMessages(count: 300) + + // Load initial 200 + MessageRepository.shared.reloadLatest(for: opponent) + + // One pagination = +50, total ~250 (below 600 threshold) + guard let earliest = MessageRepository.shared.messages(for: opponent).first else { + XCTFail("No messages") + return + } + let older = MessageRepository.shared.loadOlderMessages( + for: opponent, + beforeTimestamp: earliest.timestamp, + beforeMessageId: earliest.id, + limit: MessageRepository.pageSize + ) + + let cached = MessageRepository.shared.messages(for: opponent) + // 200 + 50 = 250 (no dedup issues since IDs are unique) + // Some may be deduped, but should be > 200 + XCTAssertGreaterThan(cached.count, MessageRepository.maxCacheSize, + "Cache should grow beyond maxCacheSize during pagination") + XCTAssertLessThanOrEqual(cached.count, MessageRepository.maxCacheSize * 3, + "Cache should stay below soft limit") + } + + // MARK: - loadNewerMessages Soft Trim + + /// When cache exceeds 3× maxCacheSize via loadNewerMessages, + /// oldest messages are trimmed to 2× maxCacheSize. + func testLoadNewerTrimsCacheWhenExceeding3xMax() async throws { + let totalMessages = 700 + try await insertMessages(count: totalMessages) + + // Load oldest 200 (simulate user scrolled all the way up) + // We'll manually load from the beginning + MessageRepository.shared.reloadLatest(for: opponent) + + // Paginate up to load oldest messages first + for _ in 0..<12 { + guard let earliest = MessageRepository.shared.messages(for: opponent).first else { break } + let older = MessageRepository.shared.loadOlderMessages( + for: opponent, + beforeTimestamp: earliest.timestamp, + beforeMessageId: earliest.id, + limit: MessageRepository.pageSize + ) + if older.isEmpty { break } + } + + // Now paginate DOWN (loadNewerMessages) to grow cache from the other direction + for _ in 0..<15 { + guard let latest = MessageRepository.shared.messages(for: opponent).last else { break } + let newer = MessageRepository.shared.loadNewerMessages( + for: opponent, + afterTimestamp: latest.timestamp, + afterMessageId: latest.id, + limit: MessageRepository.pageSize + ) + if newer.isEmpty { break } + } + + let finalCache = MessageRepository.shared.messages(for: opponent) + let hardLimit = MessageRepository.maxCacheSize * 3 + + XCTAssertLessThanOrEqual(finalCache.count, hardLimit, + "Cache must not exceed 3× maxCacheSize (\(hardLimit)) after bidirectional pagination, got \(finalCache.count)") + } + + // MARK: - reloadLatest Resets Cache + + /// reloadLatest must reset cache to exactly maxCacheSize. + func testReloadLatestResetsCacheSize() async throws { + try await insertMessages(count: 300) + + // reloadLatest loads maxCacheSize (200) messages + MessageRepository.shared.reloadLatest(for: opponent) + let initial = MessageRepository.shared.messages(for: opponent) + XCTAssertEqual(initial.count, MessageRepository.maxCacheSize, + "reloadLatest must load exactly maxCacheSize messages") + + // Paginate up to grow cache beyond maxCacheSize + for _ in 0..<3 { + guard let earliest = MessageRepository.shared.messages(for: opponent).first else { break } + let _ = MessageRepository.shared.loadOlderMessages( + for: opponent, + beforeTimestamp: earliest.timestamp, + beforeMessageId: earliest.id, + limit: MessageRepository.pageSize + ) + } + + let beforeReload = MessageRepository.shared.messages(for: opponent).count + XCTAssertGreaterThan(beforeReload, MessageRepository.maxCacheSize, + "Cache should have grown via pagination, got \(beforeReload)") + + // Jump to bottom = reloadLatest resets cache + MessageRepository.shared.reloadLatest(for: opponent) + + let afterReload = MessageRepository.shared.messages(for: opponent).count + XCTAssertEqual(afterReload, MessageRepository.maxCacheSize, + "reloadLatest must reset cache to maxCacheSize (\(MessageRepository.maxCacheSize)), got \(afterReload)") + } + + // MARK: - Message Order Preserved + + /// Messages must stay sorted by timestamp ASC after pagination trim. + func testMessageOrderPreservedAfterTrim() async throws { + try await insertMessages(count: 700) + + MessageRepository.shared.reloadLatest(for: opponent) + + // Paginate up to trigger trim + for _ in 0..<15 { + guard let earliest = MessageRepository.shared.messages(for: opponent).first else { break } + let older = MessageRepository.shared.loadOlderMessages( + for: opponent, + beforeTimestamp: earliest.timestamp, + beforeMessageId: earliest.id, + limit: MessageRepository.pageSize + ) + if older.isEmpty { break } + } + + let cached = MessageRepository.shared.messages(for: opponent) + // Verify ascending timestamp order + for i in 1.." + XCTFail("blocking voice parity findings:\n\(details)") + } + } + + private func readText(root: URL, relativePath: String) throws -> String { + let url = root.appendingPathComponent(relativePath) + return try String(contentsOf: url, encoding: .utf8) + } + + private func sha256(_ url: URL) throws -> String { + let data = try Data(contentsOf: url) + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + + private func regexCapture(text: String, pattern: String, rawMatch: Bool) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return nil + } + let nsText = text as NSString + let range = NSRange(location: 0, length: nsText.length) + guard let match = regex.firstMatch(in: text, options: [], range: range) else { + return nil + } + if rawMatch { + return pattern + } + guard match.numberOfRanges > 1 else { + return nil + } + let captureRange = match.range(at: 1) + guard captureRange.location != NSNotFound else { + return nil + } + return nsText.substring(with: captureRange) + } + + private func regexMatches(text: String, pattern: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return [] + } + let nsText = text as NSString + let range = NSRange(location: 0, length: nsText.length) + return regex.matches(in: text, options: [], range: range).compactMap { match in + guard match.numberOfRanges > 1 else { return nil } + let captureRange = match.range(at: 1) + guard captureRange.location != NSNotFound else { return nil } + return nsText.substring(with: captureRange) + } + } +} diff --git a/RosettaTests/VoiceRecordingParityMathTests.swift b/RosettaTests/VoiceRecordingParityMathTests.swift new file mode 100644 index 0000000..edec3be --- /dev/null +++ b/RosettaTests/VoiceRecordingParityMathTests.swift @@ -0,0 +1,150 @@ +import XCTest +@testable import Rosetta + +final class VoiceRecordingParityMathTests: XCTestCase { + + func testParityConstants() { + XCTAssertEqual(VoiceRecordingParityConstants.holdThreshold, 0.19, accuracy: 0.0001) + XCTAssertEqual(VoiceRecordingParityConstants.cancelDistanceThreshold, -150) + XCTAssertEqual(VoiceRecordingParityConstants.cancelHapticThreshold, -100) + XCTAssertEqual(VoiceRecordingParityConstants.lockDistanceThreshold, -110) + XCTAssertEqual(VoiceRecordingParityConstants.lockHapticThreshold, -60) + XCTAssertEqual(VoiceRecordingParityConstants.velocityGate, -400) + XCTAssertEqual(VoiceRecordingParityConstants.preHoldCancelDistance, 10) + XCTAssertEqual(VoiceRecordingParityConstants.micHitInsetX, -10) + XCTAssertEqual(VoiceRecordingParityConstants.locknessDivisor, 105) + XCTAssertEqual(VoiceRecordingParityConstants.dragNormalizeDivisor, 300) + XCTAssertEqual(VoiceRecordingParityConstants.cancelTransformThreshold, 8) + XCTAssertEqual(VoiceRecordingParityConstants.sendAccessibilityHitSize, 120) + XCTAssertEqual(VoiceRecordingParityConstants.minVoiceDuration, 0.5) + XCTAssertEqual(VoiceRecordingParityConstants.minFreeDiskBytes, 8 * 1024 * 1024) + } + + func testReleaseDecisionVelocityGate() { + let cancel = VoiceRecordingParityMath.releaseDecision( + velocityX: -500, + velocityY: 0, + distanceX: -10, + distanceY: 0 + ) + XCTAssertEqual(cancel, .cancel) + + let lock = VoiceRecordingParityMath.releaseDecision( + velocityX: 0, + velocityY: -500, + distanceX: 0, + distanceY: -10 + ) + XCTAssertEqual(lock, .lock) + } + + func testReleaseDecisionDistanceFallback() { + let cancel = VoiceRecordingParityMath.releaseDecision( + velocityX: 0, + velocityY: 0, + distanceX: -120, + distanceY: 0 + ) + XCTAssertEqual(cancel, .cancel) + + let lock = VoiceRecordingParityMath.releaseDecision( + velocityX: 0, + velocityY: 0, + distanceX: 0, + distanceY: -80 + ) + XCTAssertEqual(lock, .lock) + + let finish = VoiceRecordingParityMath.releaseDecision( + velocityX: 0, + velocityY: 0, + distanceX: -20, + distanceY: -20 + ) + XCTAssertEqual(finish, .finish) + } + + func testDominantAxis() { + XCTAssertEqual( + VoiceRecordingParityMath.dominantAxisDistances(distanceX: -110, distanceY: -40).0, + -110 + ) + XCTAssertEqual( + VoiceRecordingParityMath.dominantAxisDistances(distanceX: -110, distanceY: -40).1, + 0 + ) + + XCTAssertEqual( + VoiceRecordingParityMath.dominantAxisDistances(distanceX: -20, distanceY: -80).0, + 0 + ) + XCTAssertEqual( + VoiceRecordingParityMath.dominantAxisDistances(distanceX: -20, distanceY: -80).1, + -80 + ) + } + + func testLocknessNormalization() { + XCTAssertEqual(VoiceRecordingParityMath.lockness(distanceY: 0), 0) + XCTAssertEqual(VoiceRecordingParityMath.lockness(distanceY: -52.5), 0.5, accuracy: 0.0001) + XCTAssertEqual(VoiceRecordingParityMath.lockness(distanceY: -500), 1) + } + + func testNormalizedDrag() { + XCTAssertEqual(VoiceRecordingParityMath.normalizedDrag(distance: 0), 0, accuracy: 0.0001) + XCTAssertEqual(VoiceRecordingParityMath.normalizedDrag(distance: 150), 0.5, accuracy: 0.0001) + XCTAssertEqual(VoiceRecordingParityMath.normalizedDrag(distance: -450), 1, accuracy: 0.0001) + } + + func testCancelTransformThreshold() { + XCTAssertFalse(VoiceRecordingParityMath.shouldApplyCancelTransform(-8)) + XCTAssertTrue(VoiceRecordingParityMath.shouldApplyCancelTransform(-8.1)) + } + + func testShortRecordingDiscardGuard() { + XCTAssertTrue(VoiceRecordingParityMath.shouldDiscard(duration: 0.49)) + XCTAssertFalse(VoiceRecordingParityMath.shouldDiscard(duration: 0.5)) + XCTAssertFalse(VoiceRecordingParityMath.shouldDiscard(duration: 1.2)) + } + + func testMinTrimDurationFormula() { + let minDuration = VoiceRecordingParityConstants.minTrimDuration(duration: 10, waveformWidth: 280) + XCTAssertEqual(minDuration, 2.0, accuracy: 0.0001) + + let clamped = VoiceRecordingParityConstants.minTrimDuration(duration: 1, waveformWidth: 600) + XCTAssertEqual(clamped, 1.0, accuracy: 0.0001) + } + + func testClampTrimRange() { + let range = VoiceRecordingParityMath.clampTrimRange((-3)...(12), duration: 8) + XCTAssertEqual(range.lowerBound, 0) + XCTAssertEqual(range.upperBound, 8) + } + + func testWaveformSliceRange() { + let range = VoiceRecordingParityMath.waveformSliceRange( + sampleCount: 100, + totalDuration: 10, + trimRange: 2...7 + ) + XCTAssertEqual(range, 20..<70) + } + + func testWaveformSliceRangeInvalidInputs() { + XCTAssertNil( + VoiceRecordingParityMath.waveformSliceRange( + sampleCount: 0, + totalDuration: 10, + trimRange: 2...7 + ) + ) + XCTAssertNil( + VoiceRecordingParityMath.waveformSliceRange( + sampleCount: 100, + totalDuration: 0, + trimRange: 2...7 + ) + ) + } + +} diff --git a/RosettaUITests/RosettaUITests.swift b/RosettaUITests/RosettaUITests.swift new file mode 100644 index 0000000..6396d9d --- /dev/null +++ b/RosettaUITests/RosettaUITests.swift @@ -0,0 +1,172 @@ +import XCTest + +final class RosettaUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + private func launchFixture(mode: String) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments += ["-ui-test-voice-recording-fixture"] + app.launchEnvironment["UI_TEST_VOICE_RECORDING_MODE"] = mode + app.launch() + return app + } + + private func attachScreenshot(_ app: XCUIApplication, name: String) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + @MainActor + func testVoiceRecordingFixtureIdleLoads() { + let app = launchFixture(mode: "idle") + XCTAssertTrue(app.otherElements["voice.fixture.screen"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.otherElements["voice.mic.button"].exists) + attachScreenshot(app, name: "voice-fixture-idle") + } + + @MainActor + func testVoiceRecordingFixtureRecordingUnlockedLoads() { + let app = launchFixture(mode: "recordingUnlocked") + XCTAssertTrue(app.otherElements["voice.fixture.screen"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.otherElements["voice.fixture.recordingPanel"].exists) + XCTAssertTrue(app.otherElements["voice.recording.slideToCancel"].exists) + attachScreenshot(app, name: "voice-fixture-recording-unlocked") + } + + @MainActor + func testVoiceRecordingFixtureLocking30Snapshot() { + let app = launchFixture(mode: "locking30") + XCTAssertTrue(app.otherElements["voice.fixture.screen"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.otherElements["voice.fixture.lockView"].waitForExistence(timeout: 5)) + attachScreenshot(app, name: "voice-fixture-locking-30") + } + + @MainActor + func testVoiceRecordingFixtureLocking70SnapshotAndGeometry() { + let app = launchFixture(mode: "locking70") + XCTAssertTrue(app.otherElements["voice.fixture.screen"].waitForExistence(timeout: 5)) + + let lockView = app.otherElements["voice.fixture.lockView"] + XCTAssertTrue(lockView.waitForExistence(timeout: 5)) + XCTAssertEqual(lockView.frame.width, 40, accuracy: 1.0) + XCTAssertEqual(lockView.frame.height, 72, accuracy: 1.0) + attachScreenshot(app, name: "voice-fixture-locking-70") + } + + @MainActor + func testVoiceRecordingFixtureTimerDoesNotEllipsize() { + let app = launchFixture(mode: "recordingUnlocked") + XCTAssertTrue(app.otherElements["voice.fixture.screen"].waitForExistence(timeout: 5)) + + let timer = app.staticTexts["voice.recording.timer"] + XCTAssertTrue(timer.waitForExistence(timeout: 5)) + XCTAssertFalse(timer.label.contains("…")) + XCTAssertFalse(timer.label.contains("...")) + attachScreenshot(app, name: "voice-fixture-timer") + } + + @MainActor + func testVoiceRecordingFixtureCancelDragLoads() { + let app = launchFixture(mode: "cancelDrag") + XCTAssertTrue(app.otherElements["voice.fixture.screen"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.otherElements["voice.fixture.recordingPanel"].exists) + attachScreenshot(app, name: "voice-fixture-cancel-drag") + } + + @MainActor + func testVoiceRecordingFixtureRecordingLockedHas120StopArea() { + let app = launchFixture(mode: "recordingLocked") + XCTAssertTrue(app.otherElements["voice.fixture.screen"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.otherElements["voice.fixture.lockView"].waitForExistence(timeout: 5)) + + let stopArea = app.buttons["voice.recording.stopArea"] + XCTAssertTrue(stopArea.waitForExistence(timeout: 5)) + XCTAssertGreaterThanOrEqual(stopArea.frame.width, 119.5) + XCTAssertLessThanOrEqual(stopArea.frame.width, 120.5) + XCTAssertGreaterThanOrEqual(stopArea.frame.height, 119.5) + XCTAssertLessThanOrEqual(stopArea.frame.height, 120.5) + + let stopButton = app.buttons["voice.recording.stop"] + XCTAssertTrue(stopButton.waitForExistence(timeout: 5)) + XCTAssertEqual(stopButton.frame.width, 40, accuracy: 1.0) + XCTAssertEqual(stopButton.frame.height, 40, accuracy: 1.0) + + attachScreenshot(app, name: "voice-fixture-recording-locked") + } + + @MainActor + func testVoiceRecordingFixtureLockedStopButtonIsTappable() { + let app = launchFixture(mode: "recordingLocked") + XCTAssertTrue(app.otherElements["voice.fixture.screen"].waitForExistence(timeout: 5)) + + let stopButton = app.buttons["voice.recording.stop"] + XCTAssertTrue(stopButton.waitForExistence(timeout: 5)) + stopButton.tap() + + XCTAssertTrue(app.otherElements["voice.fixture.stopTapped"].waitForExistence(timeout: 2)) + attachScreenshot(app, name: "voice-fixture-stop-tapped") + } + + @MainActor + func testVoiceRecordingFixtureStopSnapshot() { + let app = launchFixture(mode: "stop") + XCTAssertTrue(app.otherElements["voice.fixture.screen"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["voice.recording.stop"].waitForExistence(timeout: 5)) + attachScreenshot(app, name: "voice-fixture-stop") + } + + @MainActor + func testVoiceRecordingFixtureWaitingPreviewLoads() { + let app = launchFixture(mode: "waitingPreview") + XCTAssertTrue(app.otherElements["voice.fixture.screen"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.otherElements["voice.fixture.lockView"].waitForExistence(timeout: 5)) + attachScreenshot(app, name: "voice-fixture-waiting-preview") + } + + @MainActor + func testVoiceRecordingFixturePreviewLoads() { + let app = launchFixture(mode: "preview") + + XCTAssertTrue(app.otherElements["voice.fixture.screen"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.otherElements["voice.preview.panel"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["voice.preview.send"].exists) + XCTAssertTrue(app.buttons["voice.preview.recordMore"].exists) + XCTAssertTrue(app.buttons["voice.preview.delete"].exists) + XCTAssertTrue(app.buttons["voice.preview.playPause"].exists) + XCTAssertTrue(app.otherElements["voice.preview.waveform"].exists) + attachScreenshot(app, name: "voice-fixture-preview") + } + + @MainActor + func testVoiceRecordingFixturePreviewReplacesInputRowWithoutTrailingGap() { + let app = launchFixture(mode: "preview") + XCTAssertTrue(app.otherElements["voice.fixture.screen"].waitForExistence(timeout: 5)) + + let preview = app.otherElements["voice.preview.panel"] + let composer = app.otherElements["voice.fixture.composer"] + XCTAssertTrue(preview.waitForExistence(timeout: 5)) + XCTAssertTrue(composer.waitForExistence(timeout: 5)) + + XCTAssertEqual(preview.frame.minX, composer.frame.minX + 16, accuracy: 1.5) + XCTAssertEqual(preview.frame.maxX, composer.frame.maxX - 16, accuracy: 1.5) + + let composerTextView = app.textViews["voice.composer.textView"] + if composerTextView.exists { + XCTAssertFalse(composerTextView.isHittable) + } + + attachScreenshot(app, name: "voice-fixture-preview-row-parity") + } + + @MainActor + func testVoiceRecordingFixturePreviewTrimmedLoads() { + let app = launchFixture(mode: "previewTrimmed") + XCTAssertTrue(app.otherElements["voice.fixture.screen"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["voice.preview.send"].exists) + attachScreenshot(app, name: "voice-fixture-preview-trimmed") + } +} diff --git a/tools/voice_recording_parity_checker.py b/tools/voice_recording_parity_checker.py new file mode 100755 index 0000000..52e1810 --- /dev/null +++ b/tools/voice_recording_parity_checker.py @@ -0,0 +1,846 @@ +#!/usr/bin/env python3 +import argparse +import dataclasses +import datetime as dt +import hashlib +import json +import pathlib +import re +import subprocess +import sys +from typing import Any, Dict, List, Optional, Tuple + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +BASELINE_PATH = ROOT / "docs" / "voice-recording-parity-baseline.json" + +CONSTANT_SPECS = [ + { + "id": "hold_threshold", + "severity": "P0", + "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", + "pattern": r"static let holdThreshold: TimeInterval = ([0-9.]+)", + "expected": "0.19", + "telegram_file": "Telegram-iOS/submodules/TelegramUI/Components/ChatTextInputMediaRecordingButton/Sources/ChatTextInputMediaRecordingButton.swift", + "telegram_pattern": r"timeout: 0\.19", + }, + { + "id": "cancel_distance_threshold", + "severity": "P0", + "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", + "pattern": r"static let cancelDistanceThreshold: CGFloat = (-?[0-9.]+)", + "expected": "-150", + "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", + "telegram_pattern": r"distanceX < -150\.0f", + }, + { + "id": "cancel_haptic_threshold", + "severity": "P0", + "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", + "pattern": r"static let cancelHapticThreshold: CGFloat = (-?[0-9.]+)", + "expected": "-100", + "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", + "telegram_pattern": r"distanceX < -100\.0", + }, + { + "id": "lock_distance_threshold", + "severity": "P0", + "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", + "pattern": r"static let lockDistanceThreshold: CGFloat = (-?[0-9.]+)", + "expected": "-110", + "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", + "telegram_pattern": r"distanceY < -110\.0f", + }, + { + "id": "lock_haptic_threshold", + "severity": "P0", + "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", + "pattern": r"static let lockHapticThreshold: CGFloat = (-?[0-9.]+)", + "expected": "-60", + "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", + "telegram_pattern": r"distanceY < -60\.0", + }, + { + "id": "velocity_gate", + "severity": "P0", + "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", + "pattern": r"static let velocityGate: CGFloat = (-?[0-9.]+)", + "expected": "-400", + "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", + "telegram_pattern": r"velocity\.x < -400\.0f|velocity\.y < -400\.0f", + }, + { + "id": "lockness_divisor", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", + "pattern": r"static let locknessDivisor: CGFloat = ([0-9.]+)", + "expected": "105", + "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", + "telegram_pattern": r"fabs\(_targetTranslation\) / 105\.0f", + }, + { + "id": "drag_normalize_divisor", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", + "pattern": r"static let dragNormalizeDivisor: CGFloat = ([0-9.]+)", + "expected": "300", + "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", + "telegram_pattern": r"\(-distanceX\) / 300\.0f|\(-distanceY\) / 300\.0f", + }, + { + "id": "cancel_transform_threshold", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", + "pattern": r"static let cancelTransformThreshold: CGFloat = ([0-9.]+)", + "expected": "8", + "telegram_file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift", + "telegram_pattern": r"VoiceRecordingParityMath\.shouldApplyCancelTransform\(translation\)", + }, + { + "id": "send_accessibility_hit_size", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", + "pattern": r"static let sendAccessibilityHitSize: CGFloat = ([0-9.]+)", + "expected": "120", + "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", + "telegram_pattern": r"VoiceOver\.Recording\.StopAndPreview", + }, + { + "id": "min_trim_formula", + "severity": "P0", + "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", + "pattern": r"max\(1\.0, 56\.0 \* duration / max\(waveformWidth, 1\)\)", + "expected": r"max\(1\.0, 56\.0 \* duration / max\(waveformWidth, 1\)\)", + "telegram_file": "Telegram-iOS/submodules/TelegramUI/Components/Chat/ChatRecordingPreviewInputPanelNode/Sources/ChatRecordingPreviewInputPanelNode.swift", + "telegram_pattern": r"max\(1\.0, 56\.0 \* audio\.duration / waveformBackgroundFrame\.size\.width\)", + "raw_match": True, + }, +] + +FLOW_SPEC = { + "severity": "P0", + "state_file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingFlowTypes.swift", + "expected_states": [ + "idle", + "armed", + "recordingUnlocked", + "recordingLocked", + "waitingForPreview", + "draftPreview", + ], + "required_transitions": [ + { + "id": "armed", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift", + "snippet": "setRecordingFlowState(.armed)", + }, + { + "id": "recordingUnlocked", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift", + "snippet": "setRecordingFlowState(.recordingUnlocked)", + }, + { + "id": "recordingLocked", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift", + "snippet": "setRecordingFlowState(.recordingLocked)", + }, + { + "id": "waitingForPreview", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift", + "snippet": "setRecordingFlowState(.waitingForPreview)", + }, + { + "id": "draftPreview", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift", + "snippet": "setRecordingFlowState(.draftPreview)", + }, + ], +} + +ACCESSIBILITY_SPECS = [ + { + "id": "mic_button", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift", + "snippet": "micButton.accessibilityLabel = \"Voice message\"", + }, + { + "id": "stop_area", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift", + "snippet": "button.accessibilityIdentifier = \"voice.recording.stopArea\"", + }, + { + "id": "lock_stop", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift", + "snippet": "stopButton.accessibilityLabel = \"Stop recording\"", + }, + { + "id": "slide_to_cancel", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift", + "snippet": "cancelContainer.accessibilityLabel = \"Slide left to cancel recording\"", + }, + { + "id": "preview_send", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift", + "snippet": "sendButton.accessibilityLabel = \"Send recording\"", + }, + { + "id": "preview_record_more", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift", + "snippet": "recordMoreButton.accessibilityLabel = \"Record more\"", + }, + { + "id": "preview_delete", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift", + "snippet": "deleteButton.accessibilityIdentifier = \"voice.preview.delete\"", + }, + { + "id": "preview_play_pause", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift", + "snippet": "playButton.accessibilityIdentifier = \"voice.preview.playPause\"", + }, + { + "id": "preview_waveform", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift", + "snippet": "waveformContainer.accessibilityIdentifier = \"voice.preview.waveform\"", + }, +] + +GEOMETRY_SPECS = [ + { + "id": "overlay_inner_diameter", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift", + "pattern": r"private let innerDiameter: CGFloat = ([0-9.]+)", + "expected": "110", + "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", + "telegram_pattern": r"innerCircleRadius = 110\.0f", + }, + { + "id": "overlay_outer_diameter", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift", + "pattern": r"private let outerDiameter: CGFloat = ([0-9.]+)", + "expected": "160", + "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", + "telegram_pattern": r"outerCircleRadius = innerCircleRadius \+ 50\.0f", + }, + { + "id": "panel_dot_x", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift", + "pattern": r"private let dotX: CGFloat = ([0-9.]+)", + "expected": "5", + }, + { + "id": "panel_timer_x", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift", + "pattern": r"private let timerX: CGFloat = ([0-9.]+)", + "expected": "40", + }, + { + "id": "panel_dot_size", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift", + "pattern": r"private let dotSize: CGFloat = ([0-9.]+)", + "expected": "10", + }, + { + "id": "lock_panel_width", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift", + "pattern": r"private let panelWidth: CGFloat = ([0-9.]+)", + "expected": "40", + "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", + "telegram_pattern": r"CGRectMake\(0\.0f, 0\.0f, 40\.0f, 72\.0f\)", + }, + { + "id": "lock_panel_full_height", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift", + "pattern": r"private let panelFullHeight: CGFloat = ([0-9.]+)", + "expected": "72", + "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", + "telegram_pattern": r"CGRectMake\(0\.0f, 0\.0f, 40\.0f, 72\.0f\)", + }, + { + "id": "lock_panel_locked_height", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift", + "pattern": r"private let panelLockedHeight: CGFloat = ([0-9.]+)", + "expected": "40", + }, + { + "id": "lock_vertical_offset", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift", + "pattern": r"private let verticalOffset: CGFloat = ([0-9.]+)", + "expected": "122", + "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", + "telegram_pattern": r"centerPoint\.y - 122\.0f", + }, +] + +ANIMATION_SPECS = [ + { + "id": "preview_play_to_pause_frames", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift", + "snippet": "playPauseAnimationView.play(fromFrame: 0, toFrame: 41, loopMode: .playOnce)", + }, + { + "id": "preview_pause_to_play_frames", + "severity": "P1", + "file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift", + "snippet": "playPauseAnimationView.play(fromFrame: 41, toFrame: 83, loopMode: .playOnce)", + }, + { + "id": "overlay_spring_timing", + "severity": "P2", + "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift", + "snippet": "UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.55", + }, + { + "id": "lock_stop_delay", + "severity": "P2", + "file": "Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift", + "snippet": "UIView.animate(withDuration: 0.25, delay: 0.56, options: [.curveEaseOut])", + }, +] + + +@dataclasses.dataclass +class Finding: + severity: str + kind: str + layer: str + item_id: str + expected: str + actual: str + delta: str + evidence: str + + def to_dict(self) -> Dict[str, Any]: + return { + "severity": self.severity, + "kind": self.kind, + "layer": self.layer, + "id": self.item_id, + "expected": self.expected, + "actual": self.actual, + "delta": self.delta, + "evidence": self.evidence, + } + + +def read_text(path: pathlib.Path) -> str: + return path.read_text(encoding="utf-8") + + +def sha256_file(path: pathlib.Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + while True: + chunk = f.read(1024 * 64) + if not chunk: + break + h.update(chunk) + return h.hexdigest() + + +def parse_image_size(path: pathlib.Path) -> Optional[Tuple[int, int]]: + if path.suffix.lower() not in {".png", ".jpg", ".jpeg"}: + return None + try: + proc = subprocess.run( + ["/usr/bin/sips", "-g", "pixelWidth", "-g", "pixelHeight", str(path)], + check=False, + capture_output=True, + text=True, + ) + except FileNotFoundError: + return None + if proc.returncode != 0: + return None + width = None + height = None + for line in proc.stdout.splitlines(): + if "pixelWidth:" in line: + width = int(line.split(":", 1)[1].strip()) + if "pixelHeight:" in line: + height = int(line.split(":", 1)[1].strip()) + if width is None or height is None: + return None + return (width, height) + + +def parse_lottie_meta(path: pathlib.Path) -> Dict[str, Any]: + data = json.loads(path.read_text(encoding="utf-8")) + return { + "fps": data.get("fr"), + "ip": data.get("ip"), + "op": data.get("op"), + "width": data.get("w"), + "height": data.get("h"), + } + + +def discover_asset_manifest() -> Dict[str, Any]: + imagesets: List[Dict[str, Any]] = [] + assets_root = ROOT / "Rosetta" / "Assets.xcassets" + for imageset in sorted(assets_root.glob("VoiceRecording*.imageset")): + files = [] + for file_path in sorted(imageset.iterdir()): + if file_path.name == "Contents.json": + continue + file_info: Dict[str, Any] = { + "name": file_path.name, + "sha256": sha256_file(file_path), + } + size = parse_image_size(file_path) + if size is not None: + file_info["size"] = {"width": size[0], "height": size[1]} + files.append(file_info) + imagesets.append( + { + "id": imageset.name.replace(".imageset", ""), + "severity": "P1", + "path": str(imageset.relative_to(ROOT)), + "files": files, + } + ) + + lottie: List[Dict[str, Any]] = [] + lottie_root = ROOT / "Rosetta" / "Resources" / "Lottie" + for lottie_file in sorted(lottie_root.glob("voice_*.json")): + lottie.append( + { + "id": lottie_file.stem, + "severity": "P1", + "path": str(lottie_file.relative_to(ROOT)), + "sha256": sha256_file(lottie_file), + "meta": parse_lottie_meta(lottie_file), + } + ) + + return {"imagesets": imagesets, "lottie": lottie} + + +def parse_flow_states(path: pathlib.Path) -> List[str]: + text = read_text(path) + return re.findall(r"case\s+([A-Za-z_][A-Za-z0-9_]*)", text) + + +def build_baseline() -> Dict[str, Any]: + return { + "version": 1, + "generated_at": dt.datetime.now(dt.timezone.utc).isoformat(), + "constants": CONSTANT_SPECS, + "flow": { + **FLOW_SPEC, + "actual_states": parse_flow_states(ROOT / FLOW_SPEC["state_file"]), + }, + "accessibility": ACCESSIBILITY_SPECS, + "geometry": GEOMETRY_SPECS, + "animations": ANIMATION_SPECS, + "assets": discover_asset_manifest(), + } + + +def parse_actual_constant(text: str, pattern: str, raw_match: bool) -> Optional[str]: + if raw_match: + return pattern if re.search(pattern, text) else None + m = re.search(pattern, text) + if not m: + return None + return m.group(1) + + +def run_checker(baseline: Dict[str, Any]) -> Dict[str, Any]: + findings: List[Finding] = [] + + # constants + for spec in baseline.get("constants", []): + file_path = ROOT / spec["file"] + text = read_text(file_path) + raw_match = bool(spec.get("raw_match", False)) + actual = parse_actual_constant(text, spec["pattern"], raw_match) + expected = spec["expected"] + if actual is None: + findings.append( + Finding( + severity=spec["severity"], + kind="missing_pattern", + layer="constants", + item_id=spec["id"], + expected=str(expected), + actual="missing", + delta="pattern_not_found", + evidence=spec["file"], + ) + ) + elif str(actual) != str(expected): + findings.append( + Finding( + severity=spec["severity"], + kind="value_mismatch", + layer="constants", + item_id=spec["id"], + expected=str(expected), + actual=str(actual), + delta=f"{actual} != {expected}", + evidence=spec["file"], + ) + ) + + telegram_file_raw = spec.get("telegram_file") + telegram_pattern = spec.get("telegram_pattern") + if telegram_file_raw and telegram_pattern: + telegram_file = ROOT / telegram_file_raw + telegram_text = read_text(telegram_file) + if re.search(telegram_pattern, telegram_text): + continue + findings.append( + Finding( + severity=spec["severity"], + kind="telegram_reference_missing", + layer="constants", + item_id=spec["id"], + expected=telegram_pattern, + actual="missing", + delta="telegram_evidence_not_found", + evidence=telegram_file_raw, + ) + ) + + # geometry + for spec in baseline.get("geometry", []): + file_path = ROOT / spec["file"] + text = read_text(file_path) + actual = parse_actual_constant(text, spec["pattern"], bool(spec.get("raw_match", False))) + expected = spec["expected"] + if actual is None: + findings.append( + Finding( + severity=spec["severity"], + kind="missing_pattern", + layer="geometry", + item_id=spec["id"], + expected=str(expected), + actual="missing", + delta="pattern_not_found", + evidence=spec["file"], + ) + ) + elif str(actual) != str(expected): + findings.append( + Finding( + severity=spec["severity"], + kind="value_mismatch", + layer="geometry", + item_id=spec["id"], + expected=str(expected), + actual=str(actual), + delta=f"{actual} != {expected}", + evidence=spec["file"], + ) + ) + + telegram_file_raw = spec.get("telegram_file") + telegram_pattern = spec.get("telegram_pattern") + if telegram_file_raw and telegram_pattern: + telegram_file = ROOT / telegram_file_raw + telegram_text = read_text(telegram_file) + if re.search(telegram_pattern, telegram_text): + continue + findings.append( + Finding( + severity=spec["severity"], + kind="telegram_reference_missing", + layer="geometry", + item_id=spec["id"], + expected=telegram_pattern, + actual="missing", + delta="telegram_evidence_not_found", + evidence=telegram_file_raw, + ) + ) + + # flow + flow = baseline["flow"] + state_file = ROOT / flow["state_file"] + actual_states = parse_flow_states(state_file) + expected_states = flow["expected_states"] + if actual_states != expected_states: + findings.append( + Finding( + severity=flow["severity"], + kind="state_machine_mismatch", + layer="flow", + item_id="flow_states", + expected=",".join(expected_states), + actual=",".join(actual_states), + delta="state_list_diff", + evidence=flow["state_file"], + ) + ) + + for transition in flow.get("required_transitions", []): + transition_file = ROOT / transition["file"] + transition_text = read_text(transition_file) + if transition["snippet"] not in transition_text: + findings.append( + Finding( + severity=transition["severity"], + kind="transition_missing", + layer="flow", + item_id=transition["id"], + expected=transition["snippet"], + actual="missing", + delta="snippet_not_found", + evidence=transition["file"], + ) + ) + + # accessibility + for spec in baseline.get("accessibility", []): + file_path = ROOT / spec["file"] + text = read_text(file_path) + if spec["snippet"] not in text: + findings.append( + Finding( + severity=spec["severity"], + kind="accessibility_missing", + layer="accessibility", + item_id=spec["id"], + expected=spec["snippet"], + actual="missing", + delta="snippet_not_found", + evidence=spec["file"], + ) + ) + + # animation snippets + for spec in baseline.get("animations", []): + file_path = ROOT / spec["file"] + text = read_text(file_path) + if spec["snippet"] not in text: + findings.append( + Finding( + severity=spec["severity"], + kind="animation_snippet_missing", + layer="animations", + item_id=spec["id"], + expected=spec["snippet"], + actual="missing", + delta="snippet_not_found", + evidence=spec["file"], + ) + ) + + # assets/imagesets + assets = baseline.get("assets", {}) + for spec in assets.get("imagesets", []): + imageset_path = ROOT / spec["path"] + if not imageset_path.exists(): + findings.append( + Finding( + severity=spec["severity"], + kind="asset_missing", + layer="assets", + item_id=spec["id"], + expected=spec["path"], + actual="missing", + delta="imageset_missing", + evidence=spec["path"], + ) + ) + continue + for file_spec in spec.get("files", []): + file_path = imageset_path / file_spec["name"] + if not file_path.exists(): + findings.append( + Finding( + severity=spec["severity"], + kind="asset_file_missing", + layer="assets", + item_id=f"{spec['id']}/{file_spec['name']}", + expected=file_spec["name"], + actual="missing", + delta="file_missing", + evidence=str(imageset_path.relative_to(ROOT)), + ) + ) + continue + actual_sha = sha256_file(file_path) + if actual_sha != file_spec["sha256"]: + findings.append( + Finding( + severity=spec["severity"], + kind="asset_hash_mismatch", + layer="assets", + item_id=f"{spec['id']}/{file_spec['name']}", + expected=file_spec["sha256"], + actual=actual_sha, + delta="sha256_mismatch", + evidence=str(file_path.relative_to(ROOT)), + ) + ) + expected_size = file_spec.get("size") + if expected_size is not None: + actual_size = parse_image_size(file_path) + if actual_size is None: + findings.append( + Finding( + severity=spec["severity"], + kind="asset_size_missing", + layer="assets", + item_id=f"{spec['id']}/{file_spec['name']}", + expected=f"{expected_size['width']}x{expected_size['height']}", + actual="unknown", + delta="size_unavailable", + evidence=str(file_path.relative_to(ROOT)), + ) + ) + else: + if actual_size != (expected_size["width"], expected_size["height"]): + findings.append( + Finding( + severity=spec["severity"], + kind="asset_size_mismatch", + layer="assets", + item_id=f"{spec['id']}/{file_spec['name']}", + expected=f"{expected_size['width']}x{expected_size['height']}", + actual=f"{actual_size[0]}x{actual_size[1]}", + delta="size_mismatch", + evidence=str(file_path.relative_to(ROOT)), + ) + ) + + # assets/lottie + for spec in assets.get("lottie", []): + lottie_path = ROOT / spec["path"] + if not lottie_path.exists(): + findings.append( + Finding( + severity=spec["severity"], + kind="lottie_missing", + layer="assets", + item_id=spec["id"], + expected=spec["path"], + actual="missing", + delta="lottie_missing", + evidence=spec["path"], + ) + ) + continue + actual_sha = sha256_file(lottie_path) + if actual_sha != spec["sha256"]: + findings.append( + Finding( + severity=spec["severity"], + kind="lottie_hash_mismatch", + layer="assets", + item_id=spec["id"], + expected=spec["sha256"], + actual=actual_sha, + delta="sha256_mismatch", + evidence=spec["path"], + ) + ) + actual_meta = parse_lottie_meta(lottie_path) + expected_meta = spec.get("meta", {}) + for key in ["fps", "ip", "op", "width", "height"]: + if expected_meta.get(key) != actual_meta.get(key): + findings.append( + Finding( + severity=spec["severity"], + kind="lottie_meta_mismatch", + layer="assets", + item_id=f"{spec['id']}.{key}", + expected=str(expected_meta.get(key)), + actual=str(actual_meta.get(key)), + delta=f"{key}_mismatch", + evidence=spec["path"], + ) + ) + + finding_dicts = [f.to_dict() for f in findings] + counts = {"P0": 0, "P1": 0, "P2": 0, "P3": 0} + for f in findings: + counts[f.severity] = counts.get(f.severity, 0) + 1 + + return { + "generated_at": dt.datetime.now(dt.timezone.utc).isoformat(), + "summary": { + "total": len(findings), + "by_severity": counts, + }, + "findings": finding_dicts, + } + + +def severity_rank(severity: str) -> int: + order = {"P0": 0, "P1": 1, "P2": 2, "P3": 3} + return order.get(severity, 99) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Voice recording parity checker") + parser.add_argument("--baseline", default=str(BASELINE_PATH)) + parser.add_argument("--emit-baseline", action="store_true") + parser.add_argument("--report-json", action="store_true") + parser.add_argument("--pretty", action="store_true") + parser.add_argument("--fail-on", default="P1", choices=["P0", "P1", "P2", "P3", "none"]) + args = parser.parse_args() + + baseline_path = pathlib.Path(args.baseline) + + if args.emit_baseline: + baseline = build_baseline() + baseline_path.write_text(json.dumps(baseline, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + if args.report_json: + print(json.dumps({"baseline_written": str(baseline_path.relative_to(ROOT))}, ensure_ascii=False)) + else: + print(f"Baseline written: {baseline_path}") + return 0 + + if not baseline_path.exists(): + print(f"Baseline not found: {baseline_path}", file=sys.stderr) + return 2 + + baseline = json.loads(baseline_path.read_text(encoding="utf-8")) + report = run_checker(baseline) + + if args.report_json or args.pretty: + print(json.dumps(report, ensure_ascii=False, indent=2 if args.pretty else None)) + else: + total = report["summary"]["total"] + counts = report["summary"]["by_severity"] + print(f"findings={total} P0={counts.get('P0', 0)} P1={counts.get('P1', 0)} P2={counts.get('P2', 0)} P3={counts.get('P3', 0)}") + + if args.fail_on != "none": + threshold = severity_rank(args.fail_on) + for finding in report["findings"]: + if severity_rank(finding["severity"]) <= threshold: + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())