@momo-kits/native-kits 0.113.1-beta.2 → 0.113.1-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -62,11 +62,12 @@ fun Screen(
62
62
  onContentLayout: ((LayoutCoordinates) -> Unit)? = null,
63
63
  useAvoidKeyboard: Boolean = true,
64
64
  footer: @Composable (() -> Unit)? = null,
65
- headerRight: @Composable (() -> Unit)? = null,
65
+ headerRight: @Composable (() -> Unit)? = { HeaderRight() },
66
66
  fabProps: FabProps? = null,
67
67
  animatedHeader: AnimatedHeader? = null,
68
68
  layoutOffset: Dp = 56.dp,
69
69
  inputSearchProps: InputSearchProps? = null,
70
+ useAnimationSearch: Boolean = false,
70
71
  content: @Composable () -> Unit,
71
72
  ) {
72
73
  val statusBarHeight = AppStatusBar.current ?: getStatusBarHeight()
@@ -83,7 +84,7 @@ fun Screen(
83
84
  val bottomPadding = min(indicator, 21.dp)
84
85
 
85
86
  val opacity by animateFloatAsState(
86
- targetValue = ((scrollState.value / 114f)).coerceIn(0f, 1f)
87
+ targetValue = ((scrollState.value / 52f)).coerceIn(0f, 1f),
87
88
  )
88
89
 
89
90
  val headerAnimated =
@@ -93,13 +94,15 @@ fun Screen(
93
94
 
94
95
  Box(
95
96
  Modifier.fillMaxSize()
96
- .background(backgroundColor ?: AppTheme.current.colors.background.default)
97
+ .background(backgroundColor ?: AppTheme.current.colors.background.surface)
97
98
  ) {
98
99
 
99
100
  if (animatedHeader === null) {
100
101
  HeaderBackground(
101
102
  headerType = headerType,
102
103
  scrollState = scrollState.value,
104
+ inputSearchProps= inputSearchProps,
105
+ useAnimationSearch = useAnimationSearch
103
106
  )
104
107
  } else {
105
108
  Box(
@@ -108,8 +111,11 @@ fun Screen(
108
111
  .graphicsLayer { alpha = opacity }
109
112
  ) {
110
113
  HeaderBackground(
114
+ isBack = isBack,
111
115
  headerType = headerType,
112
116
  scrollState = scrollState.value,
117
+ inputSearchProps= inputSearchProps,
118
+ useAnimationSearch = useAnimationSearch
113
119
  )
114
120
  }
115
121
  }
@@ -123,7 +129,8 @@ fun Screen(
123
129
  opacity = opacity,
124
130
  animatedHeader = animatedHeader,
125
131
  inputSearchProps = inputSearchProps,
126
- scrollState = scrollState.value
132
+ scrollState = scrollState.value,
133
+ useAnimationSearch = useAnimationSearch
127
134
  )
128
135
 
129
136
  Column(
@@ -154,6 +161,10 @@ fun Screen(
154
161
  if (animatedHeader !== null) {
155
162
  Spacer(modifier = Modifier.padding(top = statusBarHeight + 52.dp + layoutOffset))
156
163
  }
164
+
165
+ if (useAnimationSearch && inputSearchProps != null) {
166
+ Spacer(modifier = Modifier.padding(top = statusBarHeight))
167
+ }
157
168
  content()
158
169
  }
159
170
  }
@@ -17,9 +17,9 @@ enum class DotSize(val size: Int) {
17
17
  }
18
18
 
19
19
  @Composable
20
- fun BadgeDot(size: DotSize = DotSize.Large) {
20
+ fun BadgeDot(size: DotSize = DotSize.Large, modifier: Modifier = Modifier) {
21
21
  Box(
22
- modifier = Modifier
22
+ modifier = modifier
23
23
  .size(size.size.dp)
24
24
  .border(width = 1.dp, color = Colors.black_01, shape = RoundedCornerShape(Radius.S))
25
25
  .background(color = Colors.red_03, shape = RoundedCornerShape(Radius.S))
@@ -75,6 +75,7 @@ fun OTPCaret() {
75
75
 
76
76
  @Composable
77
77
  fun InputOTP(
78
+ value: String = "",
78
79
  length: Int? = null,
79
80
  errorMessage: String = "",
80
81
  errorSpacing: Boolean = false,
@@ -85,9 +86,9 @@ fun InputOTP(
85
86
  onBlur: () -> Unit = {},
86
87
  dataType: String = "number",
87
88
  accessibilityLabel: String? = null,
89
+ modifier: Modifier = Modifier
88
90
  ) {
89
91
  val MAX_LENGTH = 10
90
- var value by remember { mutableStateOf("") }
91
92
  var isFocused by remember { mutableStateOf(false) }
92
93
  var isBlurred = false
93
94
 
@@ -96,7 +97,6 @@ fun InputOTP(
96
97
  (dataType != "number" || !text.any { !it.isDigit() }) &&
97
98
  (text.length < value.length || value.length < text.length)
98
99
  ) {
99
- value = text
100
100
  onChangeText(text)
101
101
  }
102
102
  }
@@ -108,7 +108,7 @@ fun InputOTP(
108
108
  onValueChange = handleChangeText,
109
109
  singleLine = true,
110
110
  keyboardOptions = KeyboardOptions.Default.copy(keyboardType = if (dataType == "number") KeyboardType.Number else KeyboardType.Ascii),
111
- modifier = Modifier.height(56.dp).onFocusChanged {
111
+ modifier = modifier.height(56.dp).onFocusChanged {
112
112
  isFocused = it.isFocused
113
113
  if (it.isFocused) {
114
114
  onFocus()
@@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.Color
31
31
  import androidx.compose.ui.text.TextStyle
32
32
  import androidx.compose.ui.text.input.KeyboardType
33
33
  import androidx.compose.ui.text.style.TextOverflow
34
+ import androidx.compose.ui.unit.Dp
34
35
  import androidx.compose.ui.unit.dp
35
36
  import androidx.compose.ui.unit.sp
36
37
  import vn.momo.kits.const.AppTheme
@@ -98,7 +99,8 @@ data class InputSearchProps(
98
99
  val keyboardActions: KeyboardActions = KeyboardActions.Default,
99
100
  val modifier: Modifier = Modifier,
100
101
  val iconModifier: Modifier = Modifier,
101
- val onClearPress: () -> Unit = {}
102
+ val onClearPress: () -> Unit = {},
103
+ val leftPosition: Dp? = null,
102
104
  )
103
105
 
104
106
  @Composable
@@ -115,14 +115,14 @@ fun PopupNotify(
115
115
  )
116
116
 
117
117
  Column(modifier = Modifier.padding(Spacing.XL)) {
118
- Text(style = Typography.titleXs, maxLines = 2, text = title)
118
+ Text(style = Typography.titleXs, maxLines = 1, text = title)
119
119
  content(Modifier.padding(top = Spacing.S))
120
120
  if (error.isNotEmpty()) {
121
121
  Text(
122
122
  text = error,
123
123
  style = Typography.descriptionXs,
124
124
  color = AppTheme.current.colors.text.hint,
125
- maxLines = 2,
125
+ maxLines = 1,
126
126
  modifier = Modifier.padding(top = Spacing.S)
127
127
  )
128
128
  }
@@ -19,12 +19,13 @@ import androidx.compose.ui.graphics.Color
19
19
  import androidx.compose.ui.layout.ContentScale
20
20
  import androidx.compose.ui.unit.dp
21
21
  import androidx.compose.ui.unit.sp
22
+ import vn.momo.kits.application.AppConfig
22
23
  import vn.momo.kits.modifier.noFeedbackClickable
23
24
 
24
25
  val defaultBanner = TrustBannerData(
25
26
  content = mapOf(
26
- "vi" to "Bảo mật thông tin & An toàn tài sản của bạn là ưu tiên hàng đầu của MoMo.",
27
- "en" to "Your data security and money safety are MoMo's top priorities."
27
+ "vi" to "An toàn tài sản & Bảo mật thông tin của bạn là ưu tiên hàng đầu của MoMo.",
28
+ "en" to "Ensuring financial security and data privacy is MoMo's highest priority."
28
29
  ),
29
30
  subContent = mapOf(
30
31
  "vi" to "Tìm hiểu thêm",
@@ -47,25 +48,25 @@ const val contentColor = 0xFF484848
47
48
  const val subContentColor = 0xFFEB2F96
48
49
 
49
50
  data class TrustBannerData(
50
- val content: Map<String, String>,
51
- val subContent: Map<String, String>,
52
- val pciImage: String,
53
- val momoImage: String,
54
- val sslImage: String,
55
- val icons: List<String>,
56
- val urlConfig: String
51
+ val content: Map<String, String> = mapOf("vi" to "", "en" to ""),
52
+ val subContent: Map<String, String> = mapOf("vi" to "", "en" to ""),
53
+ val pciImage: String? = null,
54
+ val momoImage: String? = null,
55
+ val sslImage: String? = null,
56
+ val icons: List<String>? = null,
57
+ val urlConfig: String = ""
57
58
  )
58
59
 
59
60
  @Composable
60
61
  fun TrustBanner(
61
- trustBanner: TrustBannerData = defaultBanner,
62
62
  serviceName: String = "",
63
63
  screenName: String = "",
64
64
  onPress: ((Map<String, String>) -> Unit)? = null,
65
65
  trackEvent: ((String, Map<String, String>) -> Unit)? = null,
66
66
  language: String = "vi"
67
-
68
67
  ) {
68
+ val appConfig = AppConfig.current
69
+ val trustBanner = appConfig?.trustBanner ?: defaultBanner
69
70
  val trackParams = mapOf(
70
71
  "service_name" to serviceName,
71
72
  "screen_name" to screenName,
@@ -103,15 +104,17 @@ fun TrustBanner(
103
104
  }
104
105
  )
105
106
  ) {
106
- Image(
107
- source = trustBanner.momoImage,
107
+ if (trustBanner.momoImage != null) {
108
+ Image(
109
+ source = trustBanner.momoImage,
110
+ modifier = Modifier
111
+ .width(64.dp)
112
+ .height(64.dp)
113
+ )
114
+ }
115
+ Column(
108
116
  modifier = Modifier
109
- .width(64.dp)
110
- .height(64.dp)
111
- )
112
- Column (
113
- modifier = Modifier
114
- .padding(start = 10.dp)
117
+ .padding(start = 10.dp)
115
118
  ) {
116
119
  Text(
117
120
  text = trustBanner.content[language] ?: "",
@@ -121,10 +124,13 @@ fun TrustBanner(
121
124
  color = Color(contentColor),
122
125
  maxLines = 2
123
126
  )
124
- Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
125
- Row (
127
+ Row(
128
+ modifier = Modifier.fillMaxWidth(),
129
+ horizontalArrangement = Arrangement.SpaceBetween
130
+ ) {
131
+ Row(
126
132
  verticalAlignment = Alignment.CenterVertically,
127
- ) {
133
+ ) {
128
134
  Text(
129
135
  text = trustBanner.subContent[language] ?: "",
130
136
  lineHeight = 16.sp,
@@ -138,23 +144,27 @@ fun TrustBanner(
138
144
  )
139
145
  }
140
146
  Row {
141
- Image(
142
- source = trustBanner.pciImage, modifier = Modifier
143
- .padding(end = 4.dp)
144
- .width(27.dp)
145
- .height(20.dp)
146
- ,options = Options(
147
- contentScale = ContentScale.Fit,
147
+ if (trustBanner.pciImage != null) {
148
+ Image(
149
+ source = trustBanner.pciImage, modifier = Modifier
150
+ .padding(end = 4.dp)
151
+ .width(27.dp)
152
+ .height(20.dp), options = Options(
153
+ contentScale = ContentScale.Fit,
154
+ )
148
155
  )
149
- )
150
- Image(
151
- source = trustBanner.sslImage, modifier = Modifier
152
- .width(52.dp)
153
- .height(20.dp),
154
- options = Options(
155
- contentScale = ContentScale.Fit,
156
+ }
157
+ if (trustBanner.sslImage != null) {
158
+ Image(
159
+ source = trustBanner.sslImage, modifier = Modifier
160
+ .width(52.dp)
161
+ .height(20.dp),
162
+ options = Options(
163
+ contentScale = ContentScale.Fit,
164
+ )
156
165
  )
157
- )
166
+ }
167
+
158
168
  }
159
169
  }
160
170
  }
@@ -1318,5 +1318,10 @@ val Icons = mapOf(
1318
1318
  "ic_checked" to "https://img.mservice.com.vn/app/img/kits/checked_ic.png",
1319
1319
  "ic_minus" to "https://img.mservice.com.vn/app/img/kits/minus.png",
1320
1320
  "ic_navigation" to "https://static.momocdn.net/app/img/kits/ic_navigation.png",
1321
- "ic_money_bag" to "https://static.momocdn.net/app/img/kits/ic_money_bag.png"
1321
+ "ic_money_bag" to "https://static.momocdn.net/app/img/kits/ic_money_bag.png",
1322
+ "ic_support" to "https://static.momocdn.net/app/img/kits/ic_support.png",
1323
+ "navigation_more_icon" to "https://static.momocdn.net/app/img/kits/navigation_more_icon.png",
1324
+ "help_center" to "https://static.momocdn.net/app/img/kits/help-center.png",
1325
+ "pin_star" to "https://static.momocdn.net/app/img/kits/pin_star.png",
1326
+ "pin_star_checked" to "https://static.momocdn.net/app/img/kits/pin_star_checked.png"
1322
1327
  )
@@ -0,0 +1,34 @@
1
+ import SwiftUI
2
+
3
+ public protocol KitComposeApi {
4
+ func request(funcName: String, params: Any?) -> String
5
+ func request(funcName: String, params: Any?, onResponse: ((String) -> Void)?) -> String
6
+ func requestCallback(funcName: String, params: Any?, onResponse: ((String) -> Void)?)
7
+ func removeCallback(id: String)
8
+ }
9
+
10
+ public class MiniAppContext {
11
+ public var appId: String = ""
12
+ public var appCode: String = ""
13
+ public var appName: Any? = nil
14
+ public var appIcon: String = ""
15
+ public var description: Any? = nil
16
+
17
+ public init(appId: String, appCode: String, appName: Any? = nil, appIcon: String, description: Any? = nil) {
18
+ self.appId = appId
19
+ self.appCode = appCode
20
+ self.appName = appName
21
+ self.appIcon = appIcon
22
+ self.description = description
23
+ }
24
+ }
25
+
26
+ public class ApplicationEnvironment: ObservableObject {
27
+ let applicationContext: MiniAppContext?
28
+ let composeApi: KitComposeApi
29
+
30
+ public init(applicationContext: MiniAppContext?, composeApi: KitComposeApi) {
31
+ self.applicationContext = applicationContext
32
+ self.composeApi = composeApi
33
+ }
34
+ }
@@ -105,7 +105,7 @@ public struct Header: View {
105
105
  var headerType: HeaderType = .default
106
106
  var titlePosition: TitlePosition
107
107
  var title: String
108
- var headerRight: (() -> AnyView)?
108
+ var headerRight: (()->any View)? = {HeaderRight()}
109
109
  var goBack: (() -> Void)?
110
110
  var opacity: CGFloat? = nil
111
111
  var animatedHeader: AnimatedHeader? = nil
@@ -118,7 +118,7 @@ public struct Header: View {
118
118
  headerType: HeaderType = .default,
119
119
  titlePosition: TitlePosition,
120
120
  title: String,
121
- headerRight: (() -> AnyView)? = nil,
121
+ headerRight: (()->any View)? = {HeaderRight()},
122
122
  goBack: (() -> Void)? = nil,
123
123
  opacity: CGFloat? = nil,
124
124
  animatedHeader: AnimatedHeader? = nil,
@@ -160,11 +160,12 @@ public struct Header: View {
160
160
 
161
161
  }
162
162
 
163
- Spacer() // Pushes headerRight to the right
163
+ Spacer()
164
164
 
165
- if let headerRight = headerRight {
166
- headerRight()
165
+ if (headerRight != nil) {
166
+ AnyView(headerRight!())
167
167
  }
168
+
168
169
  }
169
170
  .frame(height: 52)
170
171
  .padding(.horizontal)
@@ -204,12 +205,10 @@ public struct Header: View {
204
205
  .foregroundColor(tintIconColor)
205
206
  .frame(maxWidth: titlePosition == .center ? .infinity : nil, alignment: titlePosition == .center ? .center : .leading)
206
207
  }
207
-
208
208
  Spacer()
209
209
  }
210
210
  .frame(height: 52)
211
211
  .padding(.horizontal)
212
-
213
212
  }
214
213
  .background(Color.clear)
215
214
  }
@@ -0,0 +1,22 @@
1
+ import Foundation
2
+
3
+ public protocol ComposeApi {
4
+ func request(funcName: String, params: Any, _ completion: ((String) -> Void)?)
5
+ func request(funcName: String, params: Any) -> String
6
+ }
7
+
8
+ // Default implementation
9
+ public class ComposeApiImpl: ComposeApi {
10
+ public static let shared = ComposeApiImpl()
11
+
12
+ private init() {}
13
+
14
+ public func request(funcName: String, params: Any, _ completion: ((String) -> Void)?) {
15
+ // Implement your API request logic here
16
+ }
17
+
18
+ public func request(funcName: String, params: Any) -> String {
19
+ // Implement your API request logic here
20
+ return ""
21
+ }
22
+ }
@@ -0,0 +1,249 @@
1
+ import SwiftUI
2
+ import Foundation
3
+
4
+ public struct HeaderRightData {
5
+ var useShortcut: Bool
6
+ var useMore: Bool
7
+ var tools: [ToolGroup]
8
+ var toolCallback: ((String) -> Void)?
9
+
10
+ public init(
11
+ useShortcut: Bool = false,
12
+ useMore: Bool = false,
13
+ tools: [ToolGroup] = [],
14
+ toolCallback: ((String) -> Void)? = nil
15
+ ) {
16
+ self.useShortcut = useShortcut
17
+ self.useMore = useMore
18
+ self.tools = tools
19
+ self.toolCallback = toolCallback
20
+ }
21
+ }
22
+
23
+ public struct Tool {
24
+ var key: String
25
+ var icon: String
26
+ var showBadge: Bool
27
+ var name: [String: String]
28
+
29
+ public init(
30
+ key: String,
31
+ icon: String,
32
+ name: [String: String] = [:],
33
+ showBadge: Bool = false
34
+ ) {
35
+ self.key = key
36
+ self.icon = icon
37
+ self.showBadge = showBadge
38
+ self.name = name
39
+ }
40
+
41
+ func toMap() -> [String: Any] {
42
+ return [
43
+ "key": key,
44
+ "icon": icon,
45
+ "showBadge": showBadge,
46
+ "name": name
47
+ ]
48
+ }
49
+ }
50
+
51
+ public struct ToolGroup {
52
+ var title: [String: String]
53
+ var items: [Tool]
54
+
55
+ public init(
56
+ title: [String: String] = [:],
57
+ items: [Tool]
58
+ ) {
59
+ self.title = title
60
+ self.items = items
61
+ }
62
+
63
+ func toMap() -> [String: Any] {
64
+ return [
65
+ "title": title,
66
+ "items": items.map { $0.toMap() }
67
+ ]
68
+ }
69
+ }
70
+
71
+ struct NavigationButtonConfig {
72
+ let icon: String
73
+ let onPress: () -> Void
74
+ }
75
+
76
+ // MARK: - Components
77
+ public struct HeaderRight: View {
78
+ public init(headerRight: HeaderRightData? = nil) {
79
+ self.headerRight = headerRight
80
+ }
81
+ var headerRight: HeaderRightData?
82
+
83
+ public var body: some View {
84
+ HStack(alignment: .center) {
85
+ ToolkitHeaderRight(headerRight: headerRight)
86
+ }
87
+ }
88
+ }
89
+
90
+ struct ToolkitHeaderRight: View {
91
+ var headerRight: HeaderRightData?
92
+ @State private var isFavorite: Bool = false
93
+ @State private var isLoading: Bool = false
94
+ @EnvironmentObject private var environment: ApplicationEnvironment
95
+
96
+ // MARK: - Actions
97
+ private func onPressShortcut() {
98
+ environment.composeApi.request(
99
+ funcName: "onToolAction",
100
+ params: ["item": ["key": "onFavorite"]]
101
+ ) { _ in
102
+ isFavorite.toggle()
103
+ }
104
+ }
105
+
106
+ private func onPressHelpCenter() {
107
+ let context = environment.applicationContext
108
+ let paramMap: [String: Any] = [
109
+ "appId": context?.appId,
110
+ "code": context?.appCode,
111
+ "name": context?.appName,
112
+ "icon": context?.appIcon,
113
+ "description": context?.description
114
+ ]
115
+ environment.composeApi.request(
116
+ funcName: "showHelpCenter",
117
+ params: paramMap
118
+ ) { _ in }
119
+ }
120
+
121
+ private func onPressClose() {
122
+ environment.composeApi.request(
123
+ funcName: "dismiss",
124
+ params: ""
125
+ ) { _ in }
126
+ }
127
+
128
+ private func onPressMore() {
129
+ let context = environment.applicationContext
130
+ let params: [String: Any] = [
131
+ "tools": headerRight?.tools.map { $0.toMap() } ?? [],
132
+ "context": [
133
+ "appId": context?.appId,
134
+ "code": context?.appCode,
135
+ "name": context?.appName,
136
+ "icon": context?.appIcon,
137
+ "description": context?.description
138
+ ]
139
+ ]
140
+ environment.composeApi.request(
141
+ funcName: "showTools",
142
+ params: params
143
+ ) { response in
144
+ do {
145
+ if let jsonData = response.data(using: .utf8),
146
+ let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
147
+ let toolResponse = json["response"] as? String {
148
+ headerRight?.toolCallback?(toolResponse)
149
+ }
150
+ } catch {
151
+ print("Error parsing response:", error)
152
+ }
153
+ }
154
+ }
155
+
156
+ private func getNavigationButtonConfig() -> NavigationButtonConfig {
157
+ let totalTools = headerRight?.tools.reduce(0) { $0 + $1.items.count } ?? 0
158
+ var icon = isFavorite ? "pin_star_checked" : "pin_star"
159
+ var onClickHandler: () -> Void = onPressShortcut
160
+
161
+ if totalTools > 1 || headerRight?.useMore == true {
162
+ icon = "navigation_more_icon"
163
+ onClickHandler = onPressMore
164
+ } else if totalTools == 1, let singleTool = headerRight?.tools.first?.items.first {
165
+ icon = singleTool.icon
166
+ onClickHandler = {
167
+ headerRight?.toolCallback?(singleTool.key)
168
+ }
169
+ }
170
+
171
+ return NavigationButtonConfig(icon: icon, onPress: onClickHandler)
172
+ }
173
+
174
+ var body: some View {
175
+ let navButtonConfig = getNavigationButtonConfig()
176
+ let showBadge = headerRight?.tools.contains { group in
177
+ group.items.contains { $0.showBadge }
178
+ } ?? false
179
+
180
+ HStack(alignment: .center) {
181
+ if headerRight?.useShortcut == true {
182
+ NavigationButton(
183
+ disabled: isLoading,
184
+ icon: navButtonConfig.icon,
185
+ showBadge: showBadge,
186
+ onClick: navButtonConfig.onPress
187
+ )
188
+ }
189
+
190
+ HStack(alignment: .center, spacing: 0) {
191
+ Icon(source: "help_center", size: 20)
192
+ .padding(4)
193
+ .onTapGesture {
194
+ onPressHelpCenter()
195
+ }
196
+
197
+ Rectangle()
198
+ .fill(Colors.black20)
199
+ .frame(width: 0.5, height: 12)
200
+
201
+ Icon(source: "16_navigation_close_circle", size: 20, color: Colors.black17)
202
+ .padding(4)
203
+ .onTapGesture {
204
+ onPressClose()
205
+ }
206
+ }
207
+ .frame(width: 65, height: 28)
208
+ .background(Color.white.opacity(0.6))
209
+ .cornerRadius(14)
210
+ .overlay(
211
+ RoundedRectangle(cornerRadius: 14)
212
+ .stroke(Color.black.opacity(0.2), lineWidth: 0.2)
213
+ )
214
+ .padding(.leading, Spacing.S)
215
+ }
216
+ }
217
+ }
218
+
219
+ struct NavigationButton: View {
220
+ var disabled: Bool
221
+ var icon: String
222
+ var showBadge: Bool
223
+ var onClick: () -> Void
224
+
225
+ var body: some View {
226
+ ZStack {
227
+ Circle()
228
+ .fill(Color.white.opacity(0.6))
229
+ .overlay(
230
+ Circle()
231
+ .stroke(Color.black.opacity(0.2), lineWidth: 0.2)
232
+ )
233
+
234
+ Icon(source: icon, size: 16)
235
+
236
+ if showBadge {
237
+ BadgeDot(size: .small)
238
+ .offset(x: -2, y: -2)
239
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
240
+ }
241
+ }
242
+ .frame(width: 28, height: 28)
243
+ .onTapGesture {
244
+ if !disabled {
245
+ onClick()
246
+ }
247
+ }
248
+ }
249
+ }