From a1bbc7ecce222e50d4f8e7e1a0181bade872a3ba Mon Sep 17 00:00:00 2001 From: Alfonso Grillo Date: Thu, 26 Oct 2023 11:05:42 +0200 Subject: [PATCH] Voice messages UI polish (#1959) * Add VoiceMessageButton * Add VoiceMessageRecordBadge * Make composer transition faster * Use VoiceMessageButton * Adjust recording badge * Update localisation key * Remove animation in VoiceMessageButton * Fix preview tests * Cleanup * Update preview tests --- ElementX.xcodeproj/project.pbxproj | 4 + .../xcshareddata/swiftpm/Package.resolved | 2 +- .../VoiceMessage/VoiceMessageButton.swift | 110 ++++++++++++++++++ .../View/CompletionSuggestionView.swift | 4 +- .../View/ComposerToolbar.swift | 2 +- .../View/VoiceMessagePreviewComposer.swift | 34 +----- .../View/VoiceMessageRecordingView.swift | 19 ++- .../StaticLocationScreenViewModel.swift | 2 +- .../RoomScreen/RoomScreenViewModel.swift | 2 +- .../VoiceMessageRoomPlaybackView.swift | 33 +----- .../test_composerToolbar.Voice-Message.png | 4 +- .../test_voiceMessageButton.1.png | 3 + .../test_voiceMessagePreviewComposer.1.png | 4 +- .../test_voiceMessageRoomPlaybackView.1.png | 4 +- ...st_voiceMessageRoomTimelineView.Bubble.png | 4 +- ...est_voiceMessageRoomTimelineView.Plain.png | 4 +- 16 files changed, 157 insertions(+), 78 deletions(-) create mode 100644 ElementX/Sources/Other/VoiceMessage/VoiceMessageButton.swift create mode 100644 UnitTests/__Snapshots__/PreviewTests/test_voiceMessageButton.1.png diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index ca5ced104..12661cbc0 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -602,6 +602,7 @@ A021827B528F1EDC9101CA58 /* AppCoordinatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */; }; A0A0D2A9564BDA3FDE2E360F /* FormattedBodyText.swift in Sources */ = {isa = PBXBuildFile; fileRef = F73FF1A33198F5FAE9D34B1F /* FormattedBodyText.swift */; }; A0D7E5BD0298A97DCBDCE40B /* Prefire in Frameworks */ = {isa = PBXBuildFile; productRef = 2629CF48B33643CD5F69C612 /* Prefire */; }; + A0FAB2BA2AE92AB5008F20B3 /* VoiceMessageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0FAB2B92AE92AB5008F20B3 /* VoiceMessageButton.swift */; }; A10D6CCDE2010C09EEA1A593 /* HomeScreenRoomList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7661EFFCAA307A97D71132A /* HomeScreenRoomList.swift */; }; A14A9419105A1CD42F0511C4 /* UserIndicatorModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43005941B3A2C9671E23C85 /* UserIndicatorModalView.swift */; }; A17FAD2EBC53E17B5FD384DB /* InviteUsersScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22730A30C50AC2E3D5BA8642 /* InviteUsersScreenViewModelProtocol.swift */; }; @@ -1566,6 +1567,7 @@ A05707BF550D770168A406DB /* LoginViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModelTests.swift; sourceTree = ""; }; A057F2FDC14866C3026A89A4 /* NotificationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManagerProtocol.swift; sourceTree = ""; }; A0A01AECFF54281CF35909A6 /* MessageComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposer.swift; sourceTree = ""; }; + A0FAB2B92AE92AB5008F20B3 /* VoiceMessageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageButton.swift; sourceTree = ""; }; A12D3B1BCF920880CA8BBB6B /* UserIndicatorControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorControllerProtocol.swift; sourceTree = ""; }; A16CD2C62CB7DB78A4238485 /* ReportContentScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenCoordinator.swift; sourceTree = ""; }; A1BF12A5E7C76777C4BF0F2B /* TimelineItemMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemMenu.swift; sourceTree = ""; }; @@ -3857,6 +3859,7 @@ isa = PBXGroup; children = ( AD0FF64B0E6470F66F42E182 /* EstimatedWaveformView.swift */, + A0FAB2B92AE92AB5008F20B3 /* VoiceMessageButton.swift */, FB9EABCA9348DFA27439A809 /* WaveformCursorView.swift */, 94028A227645FA880B966211 /* WaveformSource.swift */, 03BA7958A4BB9C22CA8884EF /* WaveformViewDragGestureModifier.swift */, @@ -5574,6 +5577,7 @@ C0090506A52A1991BAF4BA68 /* NotificationSettingsChatType.swift in Sources */, AA93B3F9B5DD097DEF79F981 /* NotificationSettingsEditScreen.swift in Sources */, 53A59720F4729D9BBFFB7CAB /* NotificationSettingsEditScreenCoordinator.swift in Sources */, + A0FAB2BA2AE92AB5008F20B3 /* VoiceMessageButton.swift in Sources */, 4EA1CE0E88EA68E862FF0EA2 /* NotificationSettingsEditScreenModels.swift in Sources */, 119AE9A3FC6E0606C1146528 /* NotificationSettingsEditScreenRoomCell.swift in Sources */, D5FE90A6AF5FD5AE91BD37C7 /* NotificationSettingsEditScreenViewModel.swift in Sources */, diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5c63b33f6..c0744d988 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -262,7 +262,7 @@ { "identity" : "swiftui-introspect", "kind" : "remoteSourceControl", - "location" : "https://github.com/siteline/SwiftUI-Introspect", + "location" : "https://github.com/siteline/SwiftUI-Introspect.git", "state" : { "revision" : "b94da693e57eaf79d16464b8b7c90d09cba4e290", "version" : "0.9.2" diff --git a/ElementX/Sources/Other/VoiceMessage/VoiceMessageButton.swift b/ElementX/Sources/Other/VoiceMessage/VoiceMessageButton.swift new file mode 100644 index 000000000..8ac7cb81d --- /dev/null +++ b/ElementX/Sources/Other/VoiceMessage/VoiceMessageButton.swift @@ -0,0 +1,110 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct VoiceMessageButton: View { + @ScaledMetric private var buttonSize: CGFloat + @ScaledMetric private var imageWidth: CGFloat = 12 + @ScaledMetric private var imageHeight: CGFloat = 14 + + enum State { + case loading + case playing + case paused + } + + enum Size { + case small + case medium + } + + let state: State + let action: () -> Void + + init(state: State, size: Size, action: @escaping () -> Void) { + switch size { + case .small: + _buttonSize = .init(wrappedValue: 30) + case .medium: + _buttonSize = .init(wrappedValue: 36) + } + + self.state = state + self.action = action + } + + var body: some View { + Button(action: action) { + buttonLabel + .frame(width: buttonSize, height: buttonSize) + } + .animation(nil, value: state) + .buttonStyle(.plain) + .disabled(state == .loading) + .background(Circle().foregroundColor(.compound.bgCanvasDefault)) + } + + @ViewBuilder + private var buttonLabel: some View { + switch state { + case .loading: + ProgressView() + case .playing, .paused: + let imageAsset = state == .playing ? Asset.Images.mediaPause : Asset.Images.mediaPlay + let accessibilityLabel = state == .playing ? L10n.a11yPause : L10n.a11yPlay + let offset: CGFloat = state == .playing ? 0 : 2 + + Image(asset: imageAsset) + .resizable() + .scaledToFit() + .frame(width: imageWidth, height: imageHeight) + .offset(x: offset) + .foregroundColor(.compound.iconSecondary) + .accessibilityLabel(accessibilityLabel) + } + } +} + +extension VoiceMessageButton.State { + init(state: AudioPlayerPlaybackState) { + switch state { + case .loading: + self = .loading + case .playing: + self = .playing + default: + self = .paused + } + } +} + +struct VoiceMessageButton_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + VStack { + HStack { + VoiceMessageButton(state: .paused, size: .small, action: { }) + VoiceMessageButton(state: .paused, size: .medium, action: { }) + } + HStack { + VoiceMessageButton(state: .playing, size: .small, action: { }) + VoiceMessageButton(state: .playing, size: .medium, action: { }) + } + } + .padding() + .background(Color.gray) + } +} diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/CompletionSuggestionView.swift b/ElementX/Sources/Screens/ComposerToolbar/View/CompletionSuggestionView.swift index f072fbb9b..d31af494d 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/CompletionSuggestionView.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/CompletionSuggestionView.swift @@ -85,8 +85,8 @@ struct CompletionSuggestionView: View { } func body(content: Content) -> some View { - var topPadding: CGFloat = isFirst ? Constants.topPadding : Constants.listItemPadding - var bottomPadding: CGFloat = Constants.listItemPadding + let topPadding: CGFloat = isFirst ? Constants.topPadding : Constants.listItemPadding + let bottomPadding: CGFloat = Constants.listItemPadding return content .padding(.top, topPadding) diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift index 080c732a8..31d717eb3 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift @@ -94,7 +94,7 @@ struct ComposerToolbar: View { } } } - .animation(.elementDefault, value: context.viewState.composerMode) + .animation(.linear(duration: 0.15), value: context.viewState.composerMode) } private var bottomBar: some View { diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessagePreviewComposer.swift b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessagePreviewComposer.swift index bd77e4ceb..1d27cab62 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessagePreviewComposer.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessagePreviewComposer.swift @@ -29,9 +29,7 @@ struct VoiceMessagePreviewComposer: View { let onPlay: () -> Void let onPause: () -> Void let onSeek: (Double) -> Void - - @ScaledMetric private var playPauseButtonSize = 32 - @ScaledMetric private var playPauseImagePadding = 8 + @State var dragState: WaveformViewDragState = .inactive private static let elapsedTimeFormatter: DateFormatter = { @@ -55,7 +53,9 @@ struct VoiceMessagePreviewComposer: View { var body: some View { HStack { HStack { - playPauseButton + VoiceMessageButton(state: .init(state: playerState.playbackState), + size: .small, + action: onPlayPause) Text(timeLabelContent) .lineLimit(1) .font(.compound.bodySMSemibold) @@ -112,32 +112,6 @@ struct VoiceMessagePreviewComposer: View { } } - @ViewBuilder - private var playPauseButton: some View { - Button { - onPlayPause() - } label: { - ZStack { - Circle() - .foregroundColor(.compound.bgCanvasDefault) - if playerState.playbackState == .loading { - ProgressView() - } else { - Image(asset: playerState.playbackState == .playing ? Asset.Images.mediaPause : Asset.Images.mediaPlay) - .resizable() - .padding(playPauseImagePadding) - .offset(x: playerState.playbackState == .playing ? 0 : 2) - .aspectRatio(contentMode: .fit) - .foregroundColor(.compound.iconSecondary) - .accessibilityLabel(playerState.playbackState == .playing ? L10n.a11yPause : L10n.a11yPlay) - } - } - } - .buttonStyle(.plain) - .disabled(playerState.playbackState == .loading) - .frame(width: playPauseButtonSize, height: playPauseButtonSize) - } - private func onPlayPause() { if playerState.playbackState == .playing { onPause() diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingView.swift b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingView.swift index b52e6e8ac..f2865fd43 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingView.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingView.swift @@ -43,9 +43,9 @@ struct VoiceMessageRecordingView: View { var body: some View { HStack { - Circle() + VoiceMessageRecordingBadge() .frame(width: recordingIndicatorSize, height: recordingIndicatorSize) - .foregroundColor(.red) + Text(timeLabelContent) .lineLimit(1) .font(.compound.bodySMSemibold) @@ -61,6 +61,21 @@ struct VoiceMessageRecordingView: View { } } +private struct VoiceMessageRecordingBadge: View { + @State private var opacity: CGFloat = 0 + + var body: some View { + Circle() + .foregroundColor(.red) + .opacity(opacity) + .onAppear { + withElementAnimation(.easeOut(duration: 1).repeatForever(autoreverses: true)) { + opacity = 1 + } + } + } +} + struct VoiceMessageRecordingView_Previews: PreviewProvider, TestablePreview { static let recorderState = AudioRecorderState() diff --git a/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenViewModel.swift b/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenViewModel.swift index 51d879145..426f850a6 100644 --- a/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenViewModel.swift +++ b/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenViewModel.swift @@ -48,7 +48,7 @@ class StaticLocationScreenViewModel: StaticLocationScreenViewModelType, StaticLo let action: () -> Void = { [weak self] in self?.actionsSubject.send(.openSystemSettings) } state.bindings.alertInfo = .init(locationSharingViewError: .missingAuthorization, primaryButton: .init(title: L10n.actionNotNow, role: .cancel, action: nil), - secondaryButton: .init(title: L10n.actionOpenSettings, action: action)) + secondaryButton: .init(title: L10n.commonSettings, action: action)) } } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index d85fe3b76..405db5f79 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -962,7 +962,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol state.bindings.confirmationAlertInfo = .init(id: .init(), title: L10n.dialogPermissionMicrophoneTitleIos(InfoPlistReader.main.bundleDisplayName), message: L10n.dialogPermissionMicrophoneDescriptionIos, - primaryButton: .init(title: L10n.actionOpenSettings, action: { [weak self] in self?.openSystemSettings() }), + primaryButton: .init(title: L10n.commonSettings, action: { [weak self] in self?.openSystemSettings() }), secondaryButton: .init(title: L10n.actionNotNow, role: .cancel, action: nil)) default: MXLog.error("failed to start voice message recording: \(error)") diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomPlaybackView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomPlaybackView.swift index 113ff36d9..74b47e33e 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomPlaybackView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomPlaybackView.swift @@ -29,9 +29,6 @@ struct VoiceMessageRoomPlaybackView: View { private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) @State private var sendFeedback = false - - @ScaledMetric private var playPauseButtonSize = 32 - @ScaledMetric private var playPauseImagePadding = 8 private static let elapsedTimeFormatter: DateFormatter = { let dateFormatter = DateFormatter() @@ -66,7 +63,9 @@ struct VoiceMessageRoomPlaybackView: View { var body: some View { HStack { HStack { - playPauseButton + VoiceMessageButton(state: .init(state: playerState.playbackState), + size: .medium, + action: onPlayPause) Text(timeLabelContent) .lineLimit(1) .font(.compound.bodySMSemibold) @@ -101,32 +100,6 @@ struct VoiceMessageRoomPlaybackView: View { .padding(.leading, 2) .padding(.trailing, 8) } - - @ViewBuilder - var playPauseButton: some View { - Button { - onPlayPause() - } label: { - ZStack { - Circle() - .foregroundColor(.compound.bgCanvasDefault) - if playerState.playbackState == .loading { - ProgressView() - } else { - Image(asset: playerState.playbackState == .playing ? Asset.Images.mediaPause : Asset.Images.mediaPlay) - .resizable() - .padding(playPauseImagePadding) - .offset(x: playerState.playbackState == .playing ? 0 : 2) - .aspectRatio(contentMode: .fit) - .foregroundColor(.compound.iconSecondary) - .accessibilityLabel(playerState.playbackState == .playing ? L10n.a11yPause : L10n.a11yPlay) - } - } - } - .disabled(playerState.playbackState == .loading) - .frame(width: playPauseButtonSize, - height: playPauseButtonSize) - } @ViewBuilder private var waveformView: some View { diff --git a/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.Voice-Message.png b/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.Voice-Message.png index f72e52bbd..bcb569293 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.Voice-Message.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.Voice-Message.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a5e4b21a9286f303e23fc85ac6ea0f108a37be0a22b02fd944dfe004de93e99 -size 93494 +oid sha256:09e3015e6bc13ff47decd46943fdee10c9c4b79d1361143f6d35e48c0822e5f8 +size 93714 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageButton.1.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageButton.1.png new file mode 100644 index 000000000..078b27855 --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageButton.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a1b3d820749df64b0d88ea7b04d95cfdd36745e0ba583fa6ad59f19fdca382b +size 67186 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessagePreviewComposer.1.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessagePreviewComposer.1.png index 82d2f6d9a..1feeeddd9 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessagePreviewComposer.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessagePreviewComposer.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e94b55edca09448dfa1309331d238f029a86b40061bad197ef8cd5c6d421a1b4 -size 62636 +oid sha256:22f419d23e9c8df9325e84bc44e0d706a88b08335a6a5e9bbc8be3deef08d961 +size 61935 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomPlaybackView.1.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomPlaybackView.1.png index 9f2384708..9e10dc2eb 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomPlaybackView.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomPlaybackView.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f08e9a60a5e93a67714b869c77e16abd6252e96515aec9951c2fac8a206950e1 -size 61376 +oid sha256:be68924b6f15ba61c3e9e18a942c724df55f6656146a3f933a74f2f0880568bf +size 61158 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomTimelineView.Bubble.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomTimelineView.Bubble.png index 656c34191..edd40461d 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomTimelineView.Bubble.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomTimelineView.Bubble.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cced4619918ea4c66bddbc0c3b957b6239c2b11a4b40b41c08a0f4d84da1d944 -size 70256 +oid sha256:c9bbb2a5218b2055e27ed7ab7770acd0e8e26973505bd28f979cfc43bffd0a64 +size 71007 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomTimelineView.Plain.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomTimelineView.Plain.png index 8fd801a80..1d96f79d1 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomTimelineView.Plain.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRoomTimelineView.Plain.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6086d159d868c2e0e9d80e19453c24fca783384864b9e6660c785cd4fd7c0986 -size 66757 +oid sha256:60cce5e7f560c36f75ae5737736ab539be31bbf094172aae9d9034f3f4fb4786 +size 67320