Refactor TimelineItemBubbledStylerView for polls (#1404)

* Refactor bubble bg colors and insets

* Improve MapLibreStaticMapView layout

* Add BubbleTimestampLayout

* Refactor BubbleTimestampLayoutType

* Refine .horizontal, .vertical layouts

* Rename layout type

* Add docs

* Restore LocationRoomTimelineItem body

* Refactor whitespaces

* Fix background color for LocationRoomTimelineView.

* Fix UTs

* Fix analytics ui tests

* Fix bug report tests

* Fix settings ui tests

* Rename private property

* Record room screen tests
This commit is contained in:
Alfonso Grillo 2023-07-26 17:17:41 +02:00 committed by GitHub
parent 99b5bf5150
commit 90ff2df00e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 209 additions and 231 deletions

View File

@ -392,7 +392,6 @@
8C1A5ECAF895D4CAF8C4D461 /* AppActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F21ED7205048668BEB44A38 /* AppActivityView.swift */; };
8C454500B8073E1201F801A9 /* MXLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A34A814CBD56230BC74FFCF4 /* MXLogger.swift */; };
8CC12086CBF91A7E10CDC205 /* HomeScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D653265D006E708E4E51AD64 /* HomeScreenCoordinator.swift */; };
8D0C5BC670D514760CC84E2A /* TextBasedRoomTimelineViewMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A542BC40D6EC2E66BC5659B /* TextBasedRoomTimelineViewMock.swift */; };
8D3E1FADD78E72504DE0E402 /* UserAgentBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB3B237387B8288A5A938F1B /* UserAgentBuilderTests.swift */; };
8D605456793F243649EC96AA /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = CD6B0C4639E066915B5E6463 /* target.yml */; };
8D71E5E53F372202379BECCE /* BugReportScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 303FCADE77DF1F3670C086ED /* BugReportScreenViewModel.swift */; };
@ -1017,7 +1016,6 @@
49D2C8E66E83EA578A7F318A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
49E751D7EDB6043238111D90 /* UNNotificationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UNNotificationRequest.swift; sourceTree = "<group>"; };
4A4AD793D50748F8997E5B15 /* TimelineItemMacContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemMacContextMenu.swift; sourceTree = "<group>"; };
4A542BC40D6EC2E66BC5659B /* TextBasedRoomTimelineViewMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextBasedRoomTimelineViewMock.swift; sourceTree = "<group>"; };
4AB7D7DAAAF662DED9D02379 /* MockMediaLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMediaLoader.swift; sourceTree = "<group>"; };
4AD6299F4516797E9BBE14C3 /* AnalyticsLocationType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsLocationType.swift; sourceTree = "<group>"; };
4B41FABA2B0AEF4389986495 /* LoginMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginMode.swift; sourceTree = "<group>"; };
@ -3207,7 +3205,6 @@
ED983D4DCA5AFA6E1ED96099 /* StateRoomTimelineView.swift */,
612EF972F2A1800682D32C5E /* StickerRoomTimelineView.swift */,
B9227F7495DA43324050A863 /* TextBasedRoomTimelineItem.swift */,
4A542BC40D6EC2E66BC5659B /* TextBasedRoomTimelineViewMock.swift */,
4E47F18A9A077E351CEA10D4 /* TextBasedRoomTimelineViewProtocol.swift */,
F9E785D5137510481733A3E8 /* TextRoomTimelineView.swift */,
F9ED8E731E21055F728E5FED /* TimelineStartRoomTimelineView.swift */,
@ -3670,14 +3667,6 @@
path = Timeline;
sourceTree = "<group>";
};
"TEMP_8B4A8977-AAD5-44D7-8CB7-F1BC31A64C84" /* element-x-ios */ = {
isa = PBXGroup;
children = (
41553551C55AD59885840F0E /* secrets.xcconfig */,
);
path = "element-x-ios";
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -4657,7 +4646,6 @@
2C4C750D0039AFABDF24236C /* TemplateScreenViewModelProtocol.swift in Sources */,
D85D4FA590305180B4A41795 /* Tests.swift in Sources */,
8317E1314C00DCCC99D30DA8 /* TextBasedRoomTimelineItem.swift in Sources */,
8D0C5BC670D514760CC84E2A /* TextBasedRoomTimelineViewMock.swift in Sources */,
A2A5AB2E8B3F5CA769E531FA /* TextBasedRoomTimelineViewProtocol.swift in Sources */,
BB784A02BADB03C820617A46 /* TextRoomTimelineItem.swift in Sources */,
53F1196F9C69512306A2693F /* TextRoomTimelineItemContent.swift in Sources */,

View File

@ -11,7 +11,7 @@
{
"identity" : "compound-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vector-im/compound-ios.git",
"location" : "https://github.com/vector-im/compound-ios",
"state" : {
"revision" : "1f3eb60c4f87249d95addf84ce1aef22c2968763"
}
@ -208,10 +208,10 @@
{
"identity" : "swiftui-introspect",
"kind" : "remoteSourceControl",
"location" : "https://github.com/siteline/SwiftUI-Introspect.git",
"location" : "https://github.com/siteline/SwiftUI-Introspect",
"state" : {
"revision" : "730ab9e6cdbb3122ad88277b295c4cecd284a311",
"version" : "0.9.1"
"revision" : "b94da693e57eaf79d16464b8b7c90d09cba4e290",
"version" : "0.9.2"
}
},
{

View File

@ -48,9 +48,7 @@ struct MapLibreStaticMapView<PinAnnotation: View>: View {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
Image("mapBlurred")
.resizable()
.aspectRatio(contentMode: .fill)
placeholderImage
case .success(let image):
ZStack {
image
@ -64,19 +62,26 @@ struct MapLibreStaticMapView<PinAnnotation: View>: View {
EmptyView()
}
}
.position(x: geometry.frame(in: .local).midX, y: geometry.frame(in: .local).midY)
.id(fetchAttempt)
} else {
placeholderImage
}
}
}
private var placeholderImage: some View {
Image("mapBlurred")
}
}
.resizable()
.scaledToFill()
}
private var errorView: some View {
Button {
fetchAttempt += 1
} label: {
ZStack {
Image("mapBlurred")
placeholderImage
.overlay {
VStack {
Image(systemName: "arrow.clockwise")
Text(L10n.actionStaticMapLoad)

View File

@ -26,11 +26,9 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
@ViewBuilder let content: () -> Content
@ScaledMetric private var senderNameVerticalPadding = 3
private let cornerRadius: CGFloat = 12
@State private var showItemActionMenu = false
private var isTextItem: Bool { timelineItem is TextBasedRoomTimelineItem }
private var isEncryptedOneToOneRoom: Bool { context.viewState.isEncryptedOneToOneRoom }
/// The base padding applied to bubbles on either side.
@ -150,50 +148,37 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
@ViewBuilder
var styledContent: some View {
if shouldFillBubble {
contentWithTimestamp
.bubbleStyle(inset: false,
cornerRadius: cornerRadius,
.bubbleStyle(insets: timelineItem.bubbleInsets,
color: timelineItem.bubbleBackgroundColor,
corners: roundedCorners)
} else {
contentWithTimestamp
.bubbleStyle(inset: true,
color: timelineItem.isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming,
cornerRadius: cornerRadius,
corners: roundedCorners)
}
}
@ViewBuilder
var contentWithTimestamp: some View {
if isTextItem || shouldFillBubble {
ZStack(alignment: .bottomTrailing) {
timelineItem.bubbleSendInfoLayoutType
.layout {
contentWithReply
interactiveLocalizedSendInfo
}
} else {
HStack(alignment: .bottom, spacing: 4) {
contentWithReply
interactiveLocalizedSendInfo
}
}
}
@ViewBuilder
var interactiveLocalizedSendInfo: some View {
if timelineItem.hasFailedToSend {
backgroundedLocalizedSendInfo
layoutedLocalizedSendInfo
.onTapGesture {
context.sendFailedConfirmationDialogInfo = .init(itemID: timelineItem.id)
}
} else {
backgroundedLocalizedSendInfo
layoutedLocalizedSendInfo
}
}
@ViewBuilder
var backgroundedLocalizedSendInfo: some View {
if shouldFillBubble {
var layoutedLocalizedSendInfo: some View {
switch timelineItem.bubbleSendInfoLayoutType {
case .overlay(capsuleStyle: true):
localizedSendInfo
.padding(.horizontal, 4)
.padding(.vertical, 2)
@ -201,9 +186,20 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
.cornerRadius(10)
.padding(.trailing, 4)
.padding(.bottom, 4)
} else {
case .overlay(capsuleStyle: false):
localizedSendInfo
.padding(.bottom, -4)
case .horizontal:
localizedSendInfo
.padding(.bottom, 4)
.padding(.trailing, 4)
case .vertical:
GridRow {
localizedSendInfo
.padding(.bottom, 4)
.padding(.trailing, 4)
.gridColumnAlignment(.trailing)
}
}
}
@ -222,7 +218,6 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
}
.font(.compound.bodyXS)
.foregroundColor(timelineItem.hasFailedToSend ? .compound.textCriticalPrimary : .compound.textSecondary)
.padding(.bottom, shouldFillBubble ? 0 : -4)
}
@ViewBuilder
@ -258,19 +253,6 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
return timelineGroupStyle == .single || timelineGroupStyle == .first ? 8 : 0
}
private var shouldFillBubble: Bool {
switch timelineItem {
case is ImageRoomTimelineItem,
is VideoRoomTimelineItem,
is StickerRoomTimelineItem:
return true
case let locationTimelineItem as LocationRoomTimelineItem:
return locationTimelineItem.content.geoURI != nil
default:
return false
}
}
private var alignment: HorizontalAlignment {
timelineItem.isOutgoing ? .trailing : .leading
}
@ -302,13 +284,84 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
}
private extension View {
func bubbleStyle(inset: Bool, color: Color? = nil, cornerRadius: CGFloat, corners: UIRectCorner) -> some View {
padding(inset ? 8 : 0)
.background(inset ? color : nil)
func bubbleStyle(insets: CGFloat, color: Color? = nil, cornerRadius: CGFloat = 12, corners: UIRectCorner) -> some View {
padding(insets)
.background(color)
.cornerRadius(cornerRadius, corners: corners)
}
}
// Describes how the content and the send info should be arranged inside a bubble
private enum BubbleSendInfoLayoutType {
case horizontal
case vertical
case overlay(capsuleStyle: Bool)
var layout: AnyLayout {
let layout: any Layout
switch self {
case .horizontal:
layout = HStackLayout(alignment: .bottom, spacing: 4)
case .vertical:
layout = GridLayout(alignment: .leading, verticalSpacing: 4)
case .overlay:
layout = ZStackLayout(alignment: .bottomTrailing)
}
return AnyLayout(layout)
}
}
private extension EventBasedTimelineItemProtocol {
var bubbleBackgroundColor: Color? {
let defaultColor: Color = isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming
switch self {
case is ImageRoomTimelineItem,
is VideoRoomTimelineItem,
is StickerRoomTimelineItem:
return nil
default:
return defaultColor
}
}
// The insets for the full bubble content.
// Padding affecting just the "send info" should be added inside `layoutedLocalizedSendInfo`
var bubbleInsets: CGFloat {
let defaultPadding: CGFloat = 8
switch self {
case is ImageRoomTimelineItem,
is VideoRoomTimelineItem,
is StickerRoomTimelineItem:
return 0
case let locationTimelineItem as LocationRoomTimelineItem:
return locationTimelineItem.content.geoURI == nil ? defaultPadding : 0
default:
return defaultPadding
}
}
var bubbleSendInfoLayoutType: BubbleSendInfoLayoutType {
let defaultTimestampLayout: BubbleSendInfoLayoutType = .horizontal
switch self {
case is TextBasedRoomTimelineItem:
return .overlay(capsuleStyle: false)
case is ImageRoomTimelineItem,
is VideoRoomTimelineItem,
is StickerRoomTimelineItem:
return .overlay(capsuleStyle: true)
case let locationTimelineItem as LocationRoomTimelineItem:
return .overlay(capsuleStyle: locationTimelineItem.content.geoURI != nil)
default:
return defaultTimestampLayout
}
}
}
struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
static let viewModel = RoomScreenViewModel.mock

View File

@ -24,9 +24,9 @@ struct EmoteRoomTimelineView: View, TextBasedRoomTimelineViewProtocol {
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
if let attributedString = timelineItem.content.formattedBody {
FormattedBodyText(attributedString: attributedString, additionalWhitespacesCount: additionalWhitespaces)
FormattedBodyText(attributedString: attributedString, additionalWhitespacesCount: timelineItem.additionalWhitespaces(timelineStyle: timelineStyle))
} else {
FormattedBodyText(text: timelineItem.content.body, additionalWhitespacesCount: additionalWhitespaces)
FormattedBodyText(text: timelineItem.content.body, additionalWhitespacesCount: timelineItem.additionalWhitespaces(timelineStyle: timelineStyle))
}
}
}

View File

@ -34,9 +34,8 @@ struct LocationRoomTimelineView: View {
.aspectRatio(mapAspectRatio, contentMode: .fit)
.clipped()
}
.background(backgroundView)
} else {
FormattedBodyText(text: timelineItem.body)
FormattedBodyText(text: timelineItem.body, additionalWhitespacesCount: timelineItem.additionalWhitespaces(timelineStyle: timelineStyle))
}
}
}
@ -52,16 +51,6 @@ struct LocationRoomTimelineView: View {
}
}
@ViewBuilder
private var backgroundView: some View {
switch timelineStyle {
case .bubbles:
timelineItem.isOutgoing ? Color.compound._bgBubbleOutgoing : Color.compound._bgBubbleIncoming
case .plain:
EmptyView()
}
}
private let mapAspectRatio: Double = 3 / 2
private let mapMaxHeight: Double = 300
}

View File

@ -33,9 +33,9 @@ struct NoticeRoomTimelineView: View, TextBasedRoomTimelineViewProtocol {
.foregroundColor(.compound.iconSecondary)
if let attributedString = timelineItem.content.formattedBody {
FormattedBodyText(attributedString: attributedString, additionalWhitespacesCount: additionalWhitespaces)
FormattedBodyText(attributedString: attributedString, additionalWhitespacesCount: timelineItem.additionalWhitespaces(timelineStyle: timelineStyle))
} else {
FormattedBodyText(text: timelineItem.content.body, additionalWhitespacesCount: additionalWhitespaces)
FormattedBodyText(text: timelineItem.content.body, additionalWhitespacesCount: timelineItem.additionalWhitespaces(timelineStyle: timelineStyle))
}
}
.padding(.leading, 4) // Trailing padding is provided by FormattedBodyText

View File

@ -17,15 +17,3 @@
import Foundation
protocol TextBasedRoomTimelineItem: EventBasedMessageTimelineItemProtocol { }
extension TextBasedRoomTimelineItem {
/// contains the timestamp and an optional edited localised prefix
/// example: (edited) 12:17 PM
var localizedSendInfo: String {
var start = ""
if properties.isEdited {
start = "\(L10n.commonEditedSuffix) "
}
return start + timestamp
}
}

View File

@ -1,33 +0,0 @@
//
// 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 Foundation
// generated with auto mockable and customised to support the generic
class TextBasedRoomTimelineViewMock<TimelineItemType: TextBasedRoomTimelineItem>: TextBasedRoomTimelineViewProtocol {
var timelineItem: TimelineItemType {
get { underlyingTimelineItem }
set(value) { underlyingTimelineItem = value }
}
var underlyingTimelineItem: TimelineItemType!
var timelineStyle: TimelineStyle {
get { underlyingTimelineStyle }
set(value) { underlyingTimelineStyle = value }
}
var underlyingTimelineStyle: TimelineStyle!
}

View File

@ -20,22 +20,3 @@ protocol TextBasedRoomTimelineViewProtocol {
var timelineItem: TimelineItemType { get }
var timelineStyle: TimelineStyle { get }
}
extension TextBasedRoomTimelineViewProtocol {
var additionalWhitespaces: Int {
guard timelineStyle == .bubbles else {
return 0
}
var whiteSpaces = 1
timelineItem.localizedSendInfo.forEach { _ in
whiteSpaces += 1
}
// To account for the extra spacing created by the alert icon
if timelineItem.hasFailedToSend {
whiteSpaces += 3
}
return whiteSpaces
}
}

View File

@ -25,11 +25,11 @@ struct TextRoomTimelineView: View, TextBasedRoomTimelineViewProtocol {
TimelineStyler(timelineItem: timelineItem) {
if let attributedString = timelineItem.content.formattedBody {
FormattedBodyText(attributedString: attributedString,
additionalWhitespacesCount: additionalWhitespaces,
additionalWhitespacesCount: timelineItem.additionalWhitespaces(timelineStyle: timelineStyle),
boostEmojiSize: true)
} else {
FormattedBodyText(text: timelineItem.body,
additionalWhitespacesCount: additionalWhitespaces,
additionalWhitespacesCount: timelineItem.additionalWhitespaces(timelineStyle: timelineStyle),
boostEmojiSize: true)
}
}

View File

@ -53,4 +53,31 @@ extension EventBasedTimelineItemProtocol {
var hasFailedDecryption: Bool {
self is EncryptedRoomTimelineItem
}
func additionalWhitespaces(timelineStyle: TimelineStyle) -> Int {
guard timelineStyle == .bubbles else {
return 0
}
var whiteSpaces = 1
localizedSendInfo.forEach { _ in
whiteSpaces += 1
}
// To account for the extra spacing created by the alert icon
if hasFailedToSend {
whiteSpaces += 3
}
return whiteSpaces
}
/// contains the timestamp and an optional edited localised prefix
/// example: (edited) 12:17 PM
var localizedSendInfo: String {
var start = ""
if properties.isEdited {
start = "\(L10n.commonEditedSuffix) "
}
return start + timestamp
}
}

View File

@ -26,7 +26,7 @@ struct LocationRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatabl
let content: LocationRoomTimelineItemContent
var body: String {
L10n.commonSharedLocation
content.body
}
var replyDetails: TimelineItemReplyDetails?

View File

@ -21,30 +21,18 @@ final class TextBasedRoomTimelineTests: XCTestCase {
func testTextRoomTimelineItemWhitespaceEnd() {
let timestamp = "Now"
let timelineItem = TextRoomTimelineItem(id: .random, timestamp: timestamp, isOutgoing: true, isEditable: true, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
let view = TextBasedRoomTimelineViewMock<TextRoomTimelineItem>()
view.underlyingTimelineItem = timelineItem
view.timelineStyle = .bubbles
XCTAssertEqual(view.additionalWhitespaces, timestamp.count + 1)
XCTAssertEqual(timelineItem.additionalWhitespaces(timelineStyle: .bubbles), timestamp.count + 1)
}
func testTextRoomTimelineItemWhitespaceEndLonger() {
let timestamp = "10:00 AM"
let timelineItem = TextRoomTimelineItem(id: .random, timestamp: timestamp, isOutgoing: true, isEditable: true, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
let view = TextBasedRoomTimelineViewMock<TextRoomTimelineItem>()
view.underlyingTimelineItem = timelineItem
view.timelineStyle = .bubbles
XCTAssertEqual(view.additionalWhitespaces, timestamp.count + 1)
XCTAssertEqual(timelineItem.additionalWhitespaces(timelineStyle: .bubbles), timestamp.count + 1)
}
func testTextRoomTimelineItemWhitespaceEndPlain() {
let timelineItem = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: true, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
let view = TextBasedRoomTimelineViewMock<TextRoomTimelineItem>()
view.underlyingTimelineItem = timelineItem
view.timelineStyle = .plain
XCTAssertEqual(view.additionalWhitespaces, 0)
XCTAssertEqual(timelineItem.additionalWhitespaces(timelineStyle: .plain), 0)
}
func testTextRoomTimelineItemWhitespaceEndWithEdit() {
@ -52,11 +40,7 @@ final class TextBasedRoomTimelineTests: XCTestCase {
var timelineItem = TextRoomTimelineItem(id: .random, timestamp: timestamp, isOutgoing: true, isEditable: true, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
timelineItem.properties.isEdited = true
let editedCount = L10n.commonEditedSuffix.count
let view = TextBasedRoomTimelineViewMock<TextRoomTimelineItem>()
view.underlyingTimelineItem = timelineItem
view.timelineStyle = .bubbles
XCTAssertEqual(view.additionalWhitespaces, timestamp.count + editedCount + 2)
XCTAssertEqual(timelineItem.additionalWhitespaces(timelineStyle: .bubbles), timestamp.count + editedCount + 2)
}
func testTextRoomTimelineItemWhitespaceEndWithEditAndAlert() {
@ -65,10 +49,6 @@ final class TextBasedRoomTimelineTests: XCTestCase {
timelineItem.properties.isEdited = true
timelineItem.properties.deliveryStatus = .sendingFailed
let editedCount = L10n.commonEditedSuffix.count
let view = TextBasedRoomTimelineViewMock<TextRoomTimelineItem>()
view.underlyingTimelineItem = timelineItem
view.timelineStyle = .bubbles
XCTAssertEqual(view.additionalWhitespaces, timestamp.count + editedCount + 5)
XCTAssertEqual(timelineItem.additionalWhitespaces(timelineStyle: .bubbles), timestamp.count + editedCount + 5)
}
}