Skip to content

Commit d4cfd53

Browse files
feat(ios,android): fix table alignment, image forceLoad, badge model, select action, and expand test coverage
iOS fixes: - Table horizontal alignment: add horizontalCellContentAlignment to TableCell model, implement alignment fallback chain (cell > colDef > row > table) in TableCellView - Image forceLoad: add forceLoad property to Image model, bypass URL cache with timestamp parameter - Image failure placeholder: show SF Symbol placeholder instead of invisible 0x0 frame on load failure - Image selectAction: reorder modifiers so tap gesture attaches before frame expansion - Badge: make text optional, add iconPosition/shape/tooltip properties, support Square/Rounded shapes, add missing icon mappings (Calendar, Tag, ErrorCircle, etc.) Android fixes: - Image forceLoad: add forceLoad property to Image model, disable Coil memory+disk cache - TableCell alignment: add horizontalCellContentAlignment, include cell-level in fallback chain Test scripts: - Fix self-heal-dual.sh: remove local keyword outside functions, add || true to OCR checks under set -e - Expand --category all to cover 286 testable cards (add root, templates, versioned) - Add root, templates, versioned as standalone categories across all 5 test scripts
1 parent a200a04 commit d4cfd53

13 files changed

Lines changed: 355 additions & 45 deletions

File tree

android/ac-core/src/main/kotlin/com/microsoft/adaptivecards/core/models/CardElement.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ data class Image(
8787
val selectAction: CardAction? = null,
8888
val targetWidth: String? = null,
8989
val themedUrls: Map<String, String>? = null,
90+
val forceLoad: Boolean? = null,
9091
/** Explicit pixel height from JSON (e.g. "32px"). Extracted by CardElementSerializer because
9192
* the base `height` field is typed as BlockElementHeight enum. */
9293
@Transient val pixelHeight: String? = null
@@ -385,6 +386,7 @@ data class TableCell(
385386
val selectAction: CardAction? = null,
386387
val style: ContainerStyle? = null,
387388
val verticalContentAlignment: VerticalContentAlignment? = null,
389+
val horizontalCellContentAlignment: HorizontalAlignment? = null,
388390
val bleed: Boolean? = null,
389391
val backgroundImage: BackgroundImage? = null,
390392
val minHeight: String? = null,

android/ac-rendering/src/main/kotlin/com/microsoft/adaptivecards/rendering/composables/ImageView.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import androidx.compose.ui.layout.ContentScale
1818
import androidx.compose.ui.platform.LocalContext
1919
import androidx.compose.ui.unit.dp
2020
import coil.compose.AsyncImage
21+
import coil.request.CachePolicy
2122
import coil.decode.SvgDecoder
2223
import coil.request.ImageRequest
2324
import com.microsoft.adaptivecards.core.models.Image
@@ -90,6 +91,10 @@ fun ImageView(
9091
if (isSvg) {
9192
decoderFactory(SvgDecoder.Factory())
9293
}
94+
if (element.forceLoad == true) {
95+
memoryCachePolicy(CachePolicy.DISABLED)
96+
diskCachePolicy(CachePolicy.DISABLED)
97+
}
9398
}
9499
.crossfade(true)
95100
.build()

android/ac-rendering/src/main/kotlin/com/microsoft/adaptivecards/rendering/composables/MediaAndTableViews.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,9 @@ fun TableView(
144144
?: element.verticalCellContentAlignment
145145
?: VerticalContentAlignment.Top
146146

147-
// Resolve horizontal alignment (colDef > row > table)
148-
val horizontalAlign = colDef?.horizontalCellContentAlignment
147+
// Resolve horizontal alignment (cell > colDef > row > table)
148+
val horizontalAlign = cell.horizontalCellContentAlignment
149+
?: colDef?.horizontalCellContentAlignment
149150
?: row.horizontalCellContentAlignment
150151
?: element.horizontalCellContentAlignment
151152

ios/Sources/ACCore/Models/ContainerTypes.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,7 @@ public struct TableCell: Codable, Equatable, Identifiable {
464464
public let type: String = "TableCell"
465465
public var items: [CardElement]? // Optional to support cells with inline text
466466
public var style: ContainerStyle?
467+
public var horizontalCellContentAlignment: HorizontalAlignment?
467468
public var verticalContentAlignment: VerticalAlignment?
468469
public var bleed: Bool?
469470
public var backgroundImage: BackgroundImage?
@@ -485,6 +486,7 @@ public struct TableCell: Codable, Equatable, Identifiable {
485486
public init(
486487
items: [CardElement]? = nil,
487488
style: ContainerStyle? = nil,
489+
horizontalCellContentAlignment: HorizontalAlignment? = nil,
488490
verticalContentAlignment: VerticalAlignment? = nil,
489491
bleed: Bool? = nil,
490492
backgroundImage: BackgroundImage? = nil,
@@ -494,6 +496,7 @@ public struct TableCell: Codable, Equatable, Identifiable {
494496
) {
495497
self.items = items
496498
self.style = style
499+
self.horizontalCellContentAlignment = horizontalCellContentAlignment
497500
self.verticalContentAlignment = verticalContentAlignment
498501
self.bleed = bleed
499502
self.backgroundImage = backgroundImage
@@ -615,13 +618,14 @@ public struct Image: Codable, Equatable, Identifiable {
615618
public var targetWidth: String?
616619
public var themedUrls: [String: String]?
617620
public var backgroundColor: String?
621+
public var forceLoad: Bool?
618622
public var fallback: CardElement?
619623

620624
enum CodingKeys: String, CodingKey {
621625
case type, id, url, altText, size, style, width, height
622626
case horizontalAlignment, selectAction, spacing, separator
623627
case isVisible, requires, targetWidth, themedUrls
624-
case backgroundColor, fallback
628+
case backgroundColor, forceLoad, fallback
625629
}
626630

627631
// Stable identifier using id property or url as fallback
@@ -649,6 +653,7 @@ public struct Image: Codable, Equatable, Identifiable {
649653
targetWidth: String? = nil,
650654
themedUrls: [String: String]? = nil,
651655
backgroundColor: String? = nil,
656+
forceLoad: Bool? = nil,
652657
fallback: CardElement? = nil
653658
) {
654659
self.id = id
@@ -667,6 +672,7 @@ public struct Image: Codable, Equatable, Identifiable {
667672
self.targetWidth = targetWidth
668673
self.themedUrls = themedUrls
669674
self.backgroundColor = backgroundColor
675+
self.forceLoad = forceLoad
670676
self.fallback = fallback
671677
}
672678

@@ -694,6 +700,7 @@ public struct Image: Codable, Equatable, Identifiable {
694700
self.themedUrls = nil
695701
}
696702
self.backgroundColor = try container.decodeIfPresent(String.self, forKey: .backgroundColor)
703+
self.forceLoad = try container.decodeIfPresent(Bool.self, forKey: .forceLoad)
697704
self.fallback = try container.decodeIfPresent(CardElement.self, forKey: .fallback)
698705
}
699706
}

ios/Sources/ACCore/Models/Elements/Badge.swift

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,38 +10,47 @@ import Foundation
1010
public struct Badge: Codable, Equatable {
1111
public let type: String
1212
public var id: String?
13-
public var text: String
13+
public var text: String?
1414
public var style: String?
1515
public var appearance: String?
1616
public var icon: String?
17+
public var iconPosition: String?
1718
public var size: String?
19+
public var shape: String?
1820
public var horizontalAlignment: String?
1921
public var spacing: Spacing?
2022
public var isVisible: Bool?
2123
public var targetWidth: String?
24+
public var tooltip: String?
2225

2326
public init(
24-
text: String,
27+
text: String? = nil,
2528
id: String? = nil,
2629
style: String? = nil,
2730
appearance: String? = nil,
2831
icon: String? = nil,
32+
iconPosition: String? = nil,
2933
size: String? = nil,
34+
shape: String? = nil,
3035
horizontalAlignment: String? = nil,
3136
spacing: Spacing? = nil,
3237
isVisible: Bool? = nil,
33-
targetWidth: String? = nil
38+
targetWidth: String? = nil,
39+
tooltip: String? = nil
3440
) {
3541
self.type = "Badge"
3642
self.id = id
3743
self.text = text
3844
self.style = style
3945
self.appearance = appearance
4046
self.icon = icon
47+
self.iconPosition = iconPosition
4148
self.size = size
49+
self.shape = shape
4250
self.horizontalAlignment = horizontalAlignment
4351
self.spacing = spacing
4452
self.isVisible = isVisible
4553
self.targetWidth = targetWidth
54+
self.tooltip = tooltip
4655
}
4756
}

ios/Sources/ACRendering/Views/BadgeView.swift

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,45 @@ struct BadgeView: View {
1212

1313
var body: some View {
1414
HStack(spacing: 4) {
15-
if let iconName = badge.icon {
15+
if badge.iconPosition?.lowercased() != "after", let iconName = badge.icon {
16+
Image(systemName: sfSymbolName(for: iconName))
17+
.font(.system(size: fontSize))
18+
}
19+
if let text = badge.text, !text.isEmpty {
20+
Text(text)
21+
.font(.system(size: fontSize, weight: .medium))
22+
}
23+
if badge.iconPosition?.lowercased() == "after", let iconName = badge.icon {
1624
Image(systemName: sfSymbolName(for: iconName))
1725
.font(.system(size: fontSize))
1826
}
19-
Text(badge.text)
20-
.font(.system(size: fontSize, weight: .medium))
2127
}
2228
.foregroundColor(foregroundColor)
2329
.padding(.horizontal, horizontalPadding)
2430
.padding(.vertical, verticalPadding)
2531
.background(backgroundColor)
26-
.clipShape(Capsule())
32+
.clipShape(badgeShape)
2733
.overlay(
28-
Capsule()
29-
.strokeBorder(strokeColor, lineWidth: isTint ? 1 : 0)
34+
badgeShape
35+
.stroke(strokeColor, lineWidth: isTint ? 1 : 0)
3036
)
3137
.frame(
3238
maxWidth: badge.horizontalAlignment == nil ? nil : .infinity,
3339
alignment: alignment
3440
)
3541
}
3642

43+
private var badgeShape: AnyShape {
44+
switch badge.shape?.lowercased() {
45+
case "square":
46+
return AnyShape(RoundedRectangle(cornerRadius: 4))
47+
case "rounded":
48+
return AnyShape(RoundedRectangle(cornerRadius: 8))
49+
default:
50+
return AnyShape(Capsule())
51+
}
52+
}
53+
3754
private var isTint: Bool {
3855
badge.appearance?.lowercased() == "tint"
3956
}
@@ -102,7 +119,13 @@ struct BadgeView: View {
102119

103120
private func sfSymbolName(for fluentName: String) -> String {
104121
let map: [String: String] = [
122+
"calendar": "calendar",
105123
"checkmarkcircle": "checkmark.circle",
124+
"errorcircle": "xmark.circle",
125+
"imagecircle": "photo.circle",
126+
"important": "exclamationmark.circle",
127+
"tag": "tag",
128+
"tooltipquote": "text.bubble",
106129
"warning": "exclamationmark.triangle",
107130
"clock": "clock",
108131
"people": "person.2",

ios/Sources/ACRendering/Views/ImageView.swift

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ struct ImageView: View {
5959
} else if isSVG {
6060
svgView
6161
} else {
62-
AsyncImage(url: URL(string: image.url)) { phase in
62+
AsyncImage(url: imageURL) { phase in
6363
switch phase {
6464
case .empty:
6565
if let w = imageWidth, let h = imageHeight {
@@ -97,12 +97,12 @@ struct ImageView: View {
9797
}
9898
}
9999
.background(backgroundColorValue)
100-
.frame(maxWidth: .infinity, alignment: frameAlignment)
101-
.spacing(image.spacing, hostConfig: hostConfig)
102-
.separator(image.separator, hostConfig: hostConfig)
103100
.selectAction(image.selectAction) { action in
104101
actionHandler.handle(action, delegate: actionDelegate, viewModel: viewModel)
105102
}
103+
.frame(maxWidth: .infinity, alignment: frameAlignment)
104+
.spacing(image.spacing, hostConfig: hostConfig)
105+
.separator(image.separator, hostConfig: hostConfig)
106106
.accessibilityElement(label: image.altText ?? "Image")
107107
}
108108

@@ -143,6 +143,19 @@ struct ImageView: View {
143143

144144
// MARK: - Sizing
145145

146+
private var imageURL: URL? {
147+
guard let url = URL(string: image.url) else { return nil }
148+
if image.forceLoad == true {
149+
// Append cache-busting parameter to bypass URL cache
150+
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
151+
var queryItems = components?.queryItems ?? []
152+
queryItems.append(URLQueryItem(name: "_t", value: "\(Date().timeIntervalSince1970)"))
153+
components?.queryItems = queryItems
154+
return components?.url ?? url
155+
}
156+
return url
157+
}
158+
146159
private var backgroundColorValue: Color {
147160
if let bgColor = image.backgroundColor, !bgColor.isEmpty {
148161
return Color(hex: bgColor)

ios/Sources/ACRendering/Views/TableView.swift

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ struct TableView: View {
2525
cell: cell,
2626
isHeader: isHeaderRow,
2727
hostConfig: hostConfig,
28-
depth: depth
28+
depth: depth,
29+
table: table,
30+
row: row,
31+
columnDef: table.columns?.indices.contains(cellIndex) == true ? table.columns?[cellIndex] : nil
2932
)
3033
.frame(maxWidth: .infinity)
3134
.if(hasColumnWeight(at: cellIndex)) { view in
@@ -66,6 +69,9 @@ struct TableCellView: View {
6669
let isHeader: Bool
6770
let hostConfig: HostConfig
6871
var depth: Int = 0
72+
var table: ACCore.Table? = nil
73+
var row: ACCore.TableRow? = nil
74+
var columnDef: TableColumnDefinition? = nil
6975

7076
@EnvironmentObject var viewModel: CardViewModel
7177

@@ -80,7 +86,7 @@ struct TableCellView: View {
8086
AreaGridLayoutView(items: items, gridLayout: gridLayout, hostConfig: hostConfig, depth: depth)
8187
}
8288
} else {
83-
VStack(spacing: 0) {
89+
VStack(alignment: resolvedHStackAlignment, spacing: 0) {
8490
ForEach(items) { element in
8591
if viewModel.isElementVisible(elementId: element.elementId) {
8692
if isHeader {
@@ -98,28 +104,49 @@ struct TableCellView: View {
98104
.frame(minHeight: 20)
99105
}
100106
}
101-
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: verticalContentAlignment)
107+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: combinedAlignment)
102108
.frame(minHeight: minHeight)
103109
.padding(.horizontal, CGFloat(hostConfig.table.cellSpacing))
104110
.padding(.vertical, CGFloat(hostConfig.table.cellSpacing))
105111
.containerStyle(cell.style, hostConfig: hostConfig)
106112
}
107113

108-
private var verticalContentAlignment: Alignment {
109-
guard let alignment = cell.verticalContentAlignment else {
110-
return .center
111-
}
112-
114+
private var resolvedHStackAlignment: SwiftUI.HorizontalAlignment {
115+
let alignment = cell.horizontalCellContentAlignment
116+
?? columnDef?.horizontalCellContentAlignment
117+
?? row?.horizontalCellContentAlignment
118+
?? table?.horizontalCellContentAlignment
113119
switch alignment {
114-
case .top:
115-
return .top
116-
case .center:
117-
return .center
118-
case .bottom:
119-
return .bottom
120+
case .center: return .center
121+
case .right: return .trailing
122+
default: return .leading
120123
}
121124
}
122125

126+
private var combinedAlignment: Alignment {
127+
let h: SwiftUI.HorizontalAlignment = {
128+
let alignment = cell.horizontalCellContentAlignment
129+
?? columnDef?.horizontalCellContentAlignment
130+
?? row?.horizontalCellContentAlignment
131+
?? table?.horizontalCellContentAlignment
132+
switch alignment {
133+
case .center: return .center
134+
case .right: return .trailing
135+
default: return .leading
136+
}
137+
}()
138+
139+
let v: SwiftUI.VerticalAlignment = {
140+
switch cell.verticalContentAlignment {
141+
case .top: return .top
142+
case .bottom: return .bottom
143+
default: return .center
144+
}
145+
}()
146+
147+
return Alignment(horizontal: h, vertical: v)
148+
}
149+
123150
private var minHeight: CGFloat? {
124151
guard let minHeightStr = cell.minHeight else { return nil }
125152
return CGFloat(Int(minHeightStr.replacingOccurrences(of: "px", with: "")) ?? 0)

0 commit comments

Comments
 (0)