diff --git a/Apps/Examples/Examples.xcodeproj/project.pbxproj b/Apps/Examples/Examples.xcodeproj/project.pbxproj index 50ff51ec3..bfa321383 100644 --- a/Apps/Examples/Examples.xcodeproj/project.pbxproj +++ b/Apps/Examples/Examples.xcodeproj/project.pbxproj @@ -32,6 +32,8 @@ 691DE21925F2A30B00094D4A /* KPIViewExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 691DE21825F2A30B00094D4A /* KPIViewExample.swift */; }; 692F338B26556A6A009B98DA /* SideBarExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 692F338A26556A6A009B98DA /* SideBarExample.swift */; }; 69B2B5D9268A333C009AC6B3 /* KPIProgressViewExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B2B5D8268A333C009AC6B3 /* KPIProgressViewExample.swift */; }; + 6D10F8A02C7DB3F50071DD3E /* BannerMultiMessageExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D10F89F2C7DB3F50071DD3E /* BannerMultiMessageExample.swift */; }; + 6D14F05E2C9290F20053BA98 /* BannerMultiMessageCustomInitExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D14F05D2C9290F20053BA98 /* BannerMultiMessageCustomInitExample.swift */; }; 6D6E86252C50D42000EDB6F4 /* FioriButtonInListExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6E86242C50D42000EDB6F4 /* FioriButtonInListExample.swift */; }; 6D6E86292C50E5F900EDB6F4 /* FioriButtonInListMultipleLineExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6E86282C50E5F900EDB6F4 /* FioriButtonInListMultipleLineExample.swift */; }; 6D6E86672C50FDBE00EDB6F4 /* FioriButtonInCollectionExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6E86662C50FDBE00EDB6F4 /* FioriButtonInCollectionExample.swift */; }; @@ -241,6 +243,8 @@ 691DE21825F2A30B00094D4A /* KPIViewExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KPIViewExample.swift; sourceTree = ""; }; 692F338A26556A6A009B98DA /* SideBarExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideBarExample.swift; sourceTree = ""; }; 69B2B5D8268A333C009AC6B3 /* KPIProgressViewExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KPIProgressViewExample.swift; sourceTree = ""; }; + 6D10F89F2C7DB3F50071DD3E /* BannerMultiMessageExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerMultiMessageExample.swift; sourceTree = ""; }; + 6D14F05D2C9290F20053BA98 /* BannerMultiMessageCustomInitExample.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BannerMultiMessageCustomInitExample.swift; sourceTree = ""; }; 6D6E86242C50D42000EDB6F4 /* FioriButtonInListExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FioriButtonInListExample.swift; sourceTree = ""; }; 6D6E86282C50E5F900EDB6F4 /* FioriButtonInListMultipleLineExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FioriButtonInListMultipleLineExample.swift; sourceTree = ""; }; 6D6E86662C50FDBE00EDB6F4 /* FioriButtonInCollectionExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FioriButtonInCollectionExample.swift; sourceTree = ""; }; @@ -767,6 +771,8 @@ B1A98FF12C11592B00FC9998 /* BannerMessageExample.swift */, B1A98FF42C12E9A000FC9998 /* BannerMessageModifierExample.swift */, B1A98FF62C12EA1600FC9998 /* BannerMessageCustomInitExample.swift */, + 6D10F89F2C7DB3F50071DD3E /* BannerMultiMessageExample.swift */, + 6D14F05D2C9290F20053BA98 /* BannerMultiMessageCustomInitExample.swift */, ); path = BannerMessage; sourceTree = ""; @@ -1096,6 +1102,7 @@ C18868D12B32535100F865F7 /* SearchFontAndColor.swift in Sources */, 9D0B26092B9BA5C0004278A5 /* KeyValueFormViewExample.swift in Sources */, 8732C2C52C350957002110E9 /* TimelineExample.swift in Sources */, + 6D10F8A02C7DB3F50071DD3E /* BannerMultiMessageExample.swift in Sources */, 8A557A1A24C12C820098003A /* ChartsContentView.swift in Sources */, 8A5579CE24C1293C0098003A /* SettingColor.swift in Sources */, 1F55FEF32AC941FF00D7A1BE /* View+Extensions.swift in Sources */, @@ -1130,6 +1137,7 @@ B84D24F12652F343007F2373 /* ObjectHeaderSpec.swift in Sources */, B846F94A26815DF30085044B /* ContactItemCompactExamples.swift in Sources */, C106AD442B33710800FE8B35 /* SearchWithScope.swift in Sources */, + 6D14F05E2C9290F20053BA98 /* BannerMultiMessageCustomInitExample.swift in Sources */, 6DEC32042C4E49C70084DD20 /* CardFixedWidthButtonsExample.swift in Sources */, B1C7DC8129FBB13F00DC5EEB /* SPIModelExample.swift in Sources */, C106AD462B338D1300FE8B35 /* SearchWithToken.swift in Sources */, diff --git a/Apps/Examples/Examples/FioriSwiftUICore/BannerMessage/BannerMessageExample.swift b/Apps/Examples/Examples/FioriSwiftUICore/BannerMessage/BannerMessageExample.swift index 7cd38d4ee..8faa9359b 100644 --- a/Apps/Examples/Examples/FioriSwiftUICore/BannerMessage/BannerMessageExample.swift +++ b/Apps/Examples/Examples/FioriSwiftUICore/BannerMessage/BannerMessageExample.swift @@ -7,7 +7,7 @@ struct BannerMessageExample: View { NavigationLink { BannerMessageModifierExample() } label: { - Text("Modifer Example") + Text("Modifier Example") } NavigationLink { @@ -15,6 +15,14 @@ struct BannerMessageExample: View { } label: { Text("Custom Creation Example") } + + NavigationLink("Multi-Message Handling Banner", destination: BannerMultiMessageExample()) + + NavigationLink { + BannerMultiMessageCustomInitExample() + } label: { + Text("Multi-Message Handling Banner - customized") + } } } } diff --git a/Apps/Examples/Examples/FioriSwiftUICore/BannerMessage/BannerMultiMessageCustomInitExample.swift b/Apps/Examples/Examples/FioriSwiftUICore/BannerMessage/BannerMultiMessageCustomInitExample.swift new file mode 100644 index 000000000..5b800c9f4 --- /dev/null +++ b/Apps/Examples/Examples/FioriSwiftUICore/BannerMessage/BannerMultiMessageCustomInitExample.swift @@ -0,0 +1,387 @@ +// +// BannerMultiMessageExample.swift +// Examples +// +// Created by Zhang, Hengyi (external - Project) on 2024/8/27. +// Copyright © 2024 SAP. All rights reserved. +// +import FioriSwiftUICore +import RegexBuilder +import SwiftUI + +struct BannerMultiMessageCustomInitExample: View { + @State private var showingMessageDetail: Bool = false + @State private var pushContentDown: Bool = true + private var withLink: Bool = false + private var withAttachedAction: Bool = false + private var withLongText: Bool = false + private var alignmentRawValue = 1 + private var turnOnSectionHeader = true + + @State var showsErrorMessage = true + @State var showsCharCount = true + @State var allowsBeyondLimit = true + @State var hidesReadonlyHint = true + @State var showsAction = true + + @State private var firstName: String = "Com" + @State private var middleName: String = "" + @State private var lastName: String = "" + @State private var preferredName: String = "" + @State private var partnerNamePrefix: String = "" + @State private var gender: String = "Female" + @State private var emailAddress: String = "Com" + @State private var maritalStatus: String = "Married" + @State private var maritalStatusSince: String = "Dec 1, 2005" + @State private var nativePreferredLanguage: String = "English" + + // the below ids used to scroll to related items + private var firstNameId = UUID() + private var middleNameId = UUID() + private var lastNameId = UUID() + private var preferredNameId = UUID() + private var partnerNamePrefixId = UUID() + private var genderId = UUID() + private var emailAddressId = UUID() + private var maritalStatusId = UUID() + private var maritalStatusSinceId = UUID() + private var nativePreferredLanguageId = UUID() + + @State private var firstNameErrorMessage = AttributedString() + @State private var lastNameErrorMessage = AttributedString() + @State private var emailAddressErrorMessage = AttributedString() + + var body: some View { + ScrollViewReader { proxy in + List { + Section { + HStack { + Spacer() + Text("Check Result") + Spacer() + } + .popover(isPresented: self.$showingMessageDetail) { + BannerMultiMessageSheet(closeAction: { + self.showingMessageDetail = false + }, removeAction: { category, _ in + self.removeCategory(category: category) + }, turnOnSectionHeader: self.turnOnSectionHeader, bannerMultiMessages: self.$bannerMultiMessages) { id in + if let (message, category) = getItemData(with: id) { + BannerMessage(icon: { + message.icon + }, title: { + Text(self.attributedMessageTitle(title: message.title, typeDesc: message.typeDesc)) + }, closeAction: { + FioriButton { state in + if state == .normal { + self.removeItem(category: category, at: message.id) + } + } label: { _ in + Image(fioriName: "fiori.decline") + } + }, topDivider: { + EmptyView() + }, bannerTapAction: { + self.showingMessageDetail = false + proxy.scrollTo(id) + }, alignment: .leading, hideSeparator: true, messageType: message.messageType) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + self.removeItem(category: category, at: message.id) + } label: { + Image(fioriName: "fiori.delete") + } + } + } else { + EmptyView() + } + } + .presentationDetents([.medium, .large]) + } + } + .listRowSeparator(.hidden) + .environment(\.defaultMinListRowHeight, 0) + .environment(\.defaultMinListHeaderHeight, 0) + + Section { + Rectangle().fill(Color.preferredColor(.primaryGroupedBackground)) + .frame(height: 15) + .listRowInsets(EdgeInsets()) + + KeyValueItem { + Text("Effective Date") + } value: { + Text("\(self.currentDateStr)") + } + + Rectangle().fill(Color.preferredColor(.primaryGroupedBackground)) + .frame(height: 15) + .listRowInsets(EdgeInsets()) + } + .listRowSeparator(.hidden) + .environment(\.defaultMinListRowHeight, 0) + .environment(\.defaultMinListHeaderHeight, 0) + + Section { + TextFieldFormView(title: "First Name", text: self.$firstName, placeholder: "Placeholder", errorMessage: self.firstNameErrorMessage, maxTextLength: 20, hintText: nil, isCharCountEnabled: self.showsCharCount, allowsBeyondLimit: self.allowsBeyondLimit, isRequired: true, actionIcon: nil, action: nil) + .id(self.firstNameId) + + TextFieldFormView(title: "Middle Name", text: self.$middleName, placeholder: "Placeholder", errorMessage: AttributedString(""), maxTextLength: 20, hintText: nil, isCharCountEnabled: self.showsCharCount, allowsBeyondLimit: self.allowsBeyondLimit, isRequired: false, actionIcon: nil, action: nil) + .id(self.middleNameId) + + TextFieldFormView(title: "Last Name", text: self.$lastName, placeholder: "Placeholder", errorMessage: self.lastNameErrorMessage, maxTextLength: 20, hintText: nil, isCharCountEnabled: self.showsCharCount, allowsBeyondLimit: self.allowsBeyondLimit, isRequired: true, actionIcon: nil, action: nil) + .id(self.lastNameId) + + TextFieldFormView(title: "Preferred Name", text: self.$preferredName, placeholder: "Placeholder", errorMessage: AttributedString(""), maxTextLength: 20, hintText: AttributedString("Starting 2025, preferred name is a required field."), isCharCountEnabled: self.showsCharCount, allowsBeyondLimit: self.allowsBeyondLimit, isRequired: false, actionIcon: nil, action: nil) + .id(self.preferredNameId) + + TextFieldFormView(title: "Partner Name Prefix", text: self.$partnerNamePrefix, placeholder: "Placeholder", errorMessage: AttributedString(""), maxTextLength: 20, hintText: nil, isCharCountEnabled: self.showsCharCount, allowsBeyondLimit: self.allowsBeyondLimit, isRequired: false, actionIcon: nil, action: nil) + .id(self.partnerNamePrefixId) + + Rectangle().fill(Color.preferredColor(.primaryGroupedBackground)) + .frame(height: 30) + .listRowInsets(EdgeInsets()) + } header: { + Text("Name Information") + .font(.fiori(forTextStyle: .subheadline)) + .foregroundStyle(Color.preferredColor(.secondaryLabel)) + } + .listRowSeparator(.hidden, edges: .bottom) + .environment(\.defaultMinListRowHeight, 0) + .environment(\.defaultMinListHeaderHeight, 0) + + Section { + Picker(selection: self.$gender) { + Text("Female").tag("Female") + Text("Male").tag("Male") + } label: { + Text("Gender*") + .font(.fiori(forTextStyle: .subheadline, weight: .semibold)) + .foregroundStyle(Color.preferredColor(.primaryLabel)) + } + .pickerStyle(.navigationLink) + .id(self.genderId) + + TextFieldFormView(title: "Email Address", text: self.$emailAddress, placeholder: "Placeholder", errorMessage: self.emailAddressErrorMessage, maxTextLength: 100, hintText: nil, isCharCountEnabled: self.showsCharCount, allowsBeyondLimit: self.allowsBeyondLimit, isRequired: true, actionIcon: nil, action: nil) + .listRowSeparator(.hidden, edges: .bottom) + .id(self.emailAddressId) + + Picker(selection: self.$maritalStatus) { + Text("Married").tag("Married") + Text("Single").tag("Single") + } label: { + Text("Marital Status*") + .font(.fiori(forTextStyle: .subheadline, weight: .semibold)) + .foregroundStyle(Color.preferredColor(.primaryLabel)) + } + .pickerStyle(.navigationLink) + .id(self.maritalStatusId) + + KeyValueItem { + Text("Marital Status Since*") + } value: { + Text(self.maritalStatusSince) + } + .id(self.maritalStatusSinceId) + + Picker(selection: self.$nativePreferredLanguage) { + Text("English").tag("English") + } label: { + Text("Native Preferred Language") + .font(.fiori(forTextStyle: .subheadline, weight: .semibold)) + .foregroundStyle(Color.preferredColor(.primaryLabel)) + } + .pickerStyle(.navigationLink) + .id(self.nativePreferredLanguageId) + + Rectangle().fill(Color.preferredColor(.primaryGroupedBackground)) + .frame(height: 30) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + } header: { + Text("Additional Information") + .font(.fiori(forTextStyle: .subheadline)) + .foregroundStyle(Color.preferredColor(.secondaryLabel)) + } + .environment(\.defaultMinListRowHeight, 0) + .environment(\.defaultMinListHeaderHeight, 0) + } + .listStyle(.inset) + .listSectionSeparator(.hidden) + .navigationTitle("Edit") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(NSLocalizedString("Save", comment: "")) { + self.validateUploadData() + } + } + } + .onChange(of: self.firstName) { _ in + self.validateUploadData() + } + .onChange(of: self.lastName) { _ in + self.validateUploadData() + } + .onChange(of: self.emailAddress) { _ in + self.validateUploadData() + } + } + } + + func removeItem(category: String, at id: UUID?) { + for i in 0 ..< self.bannerMultiMessages.count { + let item = self.bannerMultiMessages[i] + if item.category == category { + if let id { + var messages = self.bannerMultiMessages[i].items + for index in 0 ..< messages.count where messages[index].id == id { + messages.remove(at: index) + break + } + self.bannerMultiMessages[i].items = messages + + if messages.isEmpty { + self.removeCategory(category: category) + } + } else { + self.removeCategory(category: category) + } + break + } + } + } + + func removeCategory(category: String) { + for i in 0 ..< self.bannerMultiMessages.count { + let item = self.bannerMultiMessages[i] + if item.category == category { + self.bannerMultiMessages.remove(at: i) + break + } + } + } + + func getItemData(with id: UUID) -> (BannerMessageItemModel, String)? { + for element in self.bannerMultiMessages { + for item in element.items { + if item.id == id { + return (item, element.category) + } + } + } + return nil + } + + private func attributedMessageTitle(title: String, typeDesc: String) -> AttributedString { + let attributedString = NSMutableAttributedString(string: title) + + let viewDetail = NSAttributedString(string: " View \(typeDesc)", attributes: [.foregroundColor: UIColor(Color.preferredColor(.tintColor))]) + attributedString.append(viewDetail) + return AttributedString(attributedString) + } + + var currentDateStr: String { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM dd, YYYY" + return formatter.string(from: Date()) + } + + @State var bannerMultiMessages: [BannerMessageListModel] = [] + + private var alignment: HorizontalAlignment { + switch self.alignmentRawValue { + case 0: + return .leading + case 1: + return .center + default: + return .trailing + } + } + + func validateUploadData() { + var errorMessages: [BannerMessageItemModel] = [] + + var warningMessages: [BannerMessageItemModel] = [] + + var informationMessages: [BannerMessageItemModel] = [] + + if self.firstName.isEmpty { + let tips = "First name is required." + self.firstNameErrorMessage = AttributedString(tips) + warningMessages.append(BannerMessageItemModel(id: self.firstNameId, icon: Image(fioriName: "fiori.warning"), title: tips, messageType: .critical)) + } else if self.firstName.count > 20 { + let tips = "First name is too long." + self.firstNameErrorMessage = AttributedString(tips) + errorMessages.append(BannerMessageItemModel(id: self.firstNameId, icon: Image(fioriName: "fiori.notification.3"), title: tips, messageType: .negative)) + } else { + let tips = "First name correct." + self.firstNameErrorMessage = AttributedString() + informationMessages.append(BannerMessageItemModel(id: self.firstNameId, icon: Image(fioriName: "fiori.hint"), title: tips, messageType: .positive)) + } + + if self.lastName.isEmpty { + let tips = "Last name is required." + self.lastNameErrorMessage = AttributedString(tips) + warningMessages.append(BannerMessageItemModel(id: self.lastNameId, icon: Image(fioriName: "fiori.warning"), title: tips, messageType: .critical)) + } else if self.lastName.count > 20 { + let tips = "Last name is too long." + self.lastNameErrorMessage = AttributedString(tips) + errorMessages.append(BannerMessageItemModel(id: self.lastNameId, icon: Image(fioriName: "fiori.notification.3"), title: tips, messageType: .negative)) + } else { + let tips = "Last name correct." + self.lastNameErrorMessage = AttributedString() + informationMessages.append(BannerMessageItemModel(id: self.lastNameId, icon: Image(fioriName: "fiori.hint"), title: tips, messageType: .positive)) + } + + if self.emailAddress.isEmpty { + let tips = "Email address is required!" + self.emailAddressErrorMessage = AttributedString(tips) + warningMessages.append(BannerMessageItemModel(id: self.emailAddressId, icon: Image(fioriName: "fiori.warning"), title: tips, messageType: .critical)) + } else if self.isEmailInvalid { + let tips = "Email address is Invalid!" + self.emailAddressErrorMessage = AttributedString(tips) + errorMessages.append(BannerMessageItemModel(id: self.emailAddressId, icon: Image(fioriName: "fiori.notification.3"), title: tips, messageType: .negative)) + } else { + let tips = "Email address correct." + self.emailAddressErrorMessage = AttributedString() + informationMessages.append(BannerMessageItemModel(id: self.emailAddressId, icon: Image(fioriName: "fiori.hint"), title: tips, messageType: .positive)) + } + + var result: [BannerMessageListModel] = [] + if !errorMessages.isEmpty { + result.append(BannerMessageListModel(category: "Errors", items: errorMessages)) + } + if !warningMessages.isEmpty { + result.append(BannerMessageListModel(category: "Warnings", items: warningMessages)) + } + if !informationMessages.isEmpty { + result.append(BannerMessageListModel(category: "Information", items: informationMessages)) + } + + self.bannerMultiMessages = result + + self.showingMessageDetail = !self.bannerMultiMessages.isEmpty + } + + var isEmailInvalid: Bool { + let emailRegex = Regex { + OneOrMore(.word) + "@" + ChoiceOf { + "sap" + "gmail" + } + ".com" + } + if let _ = emailAddress.wholeMatch(of: emailRegex)?.output { + return false + } + return true + } +} + +#Preview { + BannerMultiMessageExample() +} diff --git a/Apps/Examples/Examples/FioriSwiftUICore/BannerMessage/BannerMultiMessageExample.swift b/Apps/Examples/Examples/FioriSwiftUICore/BannerMessage/BannerMultiMessageExample.swift new file mode 100644 index 000000000..bba9ce66a --- /dev/null +++ b/Apps/Examples/Examples/FioriSwiftUICore/BannerMessage/BannerMultiMessageExample.swift @@ -0,0 +1,293 @@ +// +// BannerMultiMessageExample.swift +// Examples +// +// Created by Zhang, Hengyi (external - Project) on 2024/8/27. +// Copyright © 2024 SAP. All rights reserved. +// +import FioriSwiftUICore +import RegexBuilder +import SwiftUI + +struct BannerMultiMessageExample: View { + @State private var showBanner: Bool = false + @State private var pushContentDown: Bool = true + private var withLink: Bool = false + private var withAttachedAction: Bool = false + private var withLongText: Bool = false + private var alignmentRawValue = 1 + private var turnOnSectionHeader = true + + @State var showsErrorMessage = true + @State var showsCharCount = true + @State var allowsBeyondLimit = true + @State var hidesReadonlyHint = true + @State var showsAction = true + + @State private var firstName: String = "Com" + @State private var middleName: String = "" + @State private var lastName: String = "" + @State private var preferredName: String = "" + @State private var partnerNamePrefix: String = "" + @State private var gender: String = "Female" + @State private var emailAddress: String = "Com" + @State private var maritalStatus: String = "Married" + @State private var maritalStatusSince: String = "Dec 1, 2005" + @State private var nativePreferredLanguage: String = "English" + + // the below ids used to scroll to related items + private var firstNameId = UUID() + private var middleNameId = UUID() + private var lastNameId = UUID() + private var preferredNameId = UUID() + private var partnerNamePrefixId = UUID() + private var genderId = UUID() + private var emailAddressId = UUID() + private var maritalStatusId = UUID() + private var maritalStatusSinceId = UUID() + private var nativePreferredLanguageId = UUID() + + @State private var firstNameErrorMessage = AttributedString() + @State private var lastNameErrorMessage = AttributedString() + @State private var emailAddressErrorMessage = AttributedString() + + var body: some View { + ScrollViewReader { proxy in + List { + Section { + Rectangle().fill(Color.preferredColor(.primaryGroupedBackground)) + .frame(height: 15) + .listRowInsets(EdgeInsets()) + + KeyValueItem { + Text("Effective Date") + } value: { + Text("\(self.currentDateStr)") + } + + Rectangle().fill(Color.preferredColor(.primaryGroupedBackground)) + .frame(height: 15) + .listRowInsets(EdgeInsets()) + } + .listRowSeparator(.hidden) + .environment(\.defaultMinListRowHeight, 0) + .environment(\.defaultMinListHeaderHeight, 0) + + Section { + TextFieldFormView(title: "First Name", text: self.$firstName, placeholder: "Placeholder", errorMessage: self.firstNameErrorMessage, maxTextLength: 20, hintText: nil, isCharCountEnabled: self.showsCharCount, allowsBeyondLimit: self.allowsBeyondLimit, isRequired: true, actionIcon: nil, action: nil) + .id(self.firstNameId) + + TextFieldFormView(title: "Middle Name", text: self.$middleName, placeholder: "Placeholder", errorMessage: AttributedString(""), maxTextLength: 20, hintText: nil, isCharCountEnabled: self.showsCharCount, allowsBeyondLimit: self.allowsBeyondLimit, isRequired: false, actionIcon: nil, action: nil) + .id(self.middleNameId) + + TextFieldFormView(title: "Last Name", text: self.$lastName, placeholder: "Placeholder", errorMessage: self.lastNameErrorMessage, maxTextLength: 20, hintText: nil, isCharCountEnabled: self.showsCharCount, allowsBeyondLimit: self.allowsBeyondLimit, isRequired: true, actionIcon: nil, action: nil) + .id(self.lastNameId) + + TextFieldFormView(title: "Preferred Name", text: self.$preferredName, placeholder: "Placeholder", errorMessage: AttributedString(""), maxTextLength: 20, hintText: AttributedString("Starting 2025, preferred name is a required field."), isCharCountEnabled: self.showsCharCount, allowsBeyondLimit: self.allowsBeyondLimit, isRequired: false, actionIcon: nil, action: nil) + .id(self.preferredNameId) + + TextFieldFormView(title: "Partner Name Prefix", text: self.$partnerNamePrefix, placeholder: "Placeholder", errorMessage: AttributedString(""), maxTextLength: 20, hintText: nil, isCharCountEnabled: self.showsCharCount, allowsBeyondLimit: self.allowsBeyondLimit, isRequired: false, actionIcon: nil, action: nil) + .id(self.partnerNamePrefixId) + + Rectangle().fill(Color.preferredColor(.primaryGroupedBackground)) + .frame(height: 30) + .listRowInsets(EdgeInsets()) + } header: { + Text("Name Information") + .font(.fiori(forTextStyle: .subheadline)) + .foregroundStyle(Color.preferredColor(.secondaryLabel)) + } + .listRowSeparator(.hidden, edges: .bottom) + .environment(\.defaultMinListRowHeight, 0) + .environment(\.defaultMinListHeaderHeight, 0) + + Section { + Picker(selection: self.$gender) { + Text("Female").tag("Female") + Text("Male").tag("Male") + } label: { + Text("Gender*") + .font(.fiori(forTextStyle: .subheadline, weight: .semibold)) + .foregroundStyle(Color.preferredColor(.primaryLabel)) + } + .pickerStyle(.navigationLink) + .id(self.genderId) + + TextFieldFormView(title: "Email Address", text: self.$emailAddress, placeholder: "Placeholder", errorMessage: self.emailAddressErrorMessage, maxTextLength: 100, hintText: nil, isCharCountEnabled: self.showsCharCount, allowsBeyondLimit: self.allowsBeyondLimit, isRequired: true, actionIcon: nil, action: nil) + .listRowSeparator(.hidden, edges: .bottom) + .id(self.emailAddressId) + + Picker(selection: self.$maritalStatus) { + Text("Married").tag("Married") + Text("Single").tag("Single") + } label: { + Text("Marital Status*") + .font(.fiori(forTextStyle: .subheadline, weight: .semibold)) + .foregroundStyle(Color.preferredColor(.primaryLabel)) + } + .pickerStyle(.navigationLink) + .id(self.maritalStatusId) + + KeyValueItem { + Text("Marital Status Since*") + } value: { + Text(self.maritalStatusSince) + } + .id(self.maritalStatusSinceId) + + Picker(selection: self.$nativePreferredLanguage) { + Text("English").tag("English") + } label: { + Text("Native Preferred Language") + .font(.fiori(forTextStyle: .subheadline, weight: .semibold)) + .foregroundStyle(Color.preferredColor(.primaryLabel)) + } + .pickerStyle(.navigationLink) + .id(self.nativePreferredLanguageId) + + Rectangle().fill(Color.preferredColor(.primaryGroupedBackground)) + .frame(height: 30) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + } header: { + Text("Additional Information") + .font(.fiori(forTextStyle: .subheadline)) + .foregroundStyle(Color.preferredColor(.secondaryLabel)) + } + .environment(\.defaultMinListRowHeight, 0) + .environment(\.defaultMinListHeaderHeight, 0) + } + .listStyle(.inset) + .listSectionSeparator(.hidden) + .bannerMessageView(isPresented: self.$showBanner, pushContentDown: self.$pushContentDown, icon: { + Image(fioriName: "fiori.notification.3") + }, bannerTapped: { + print("banner is tapped") + }, viewDetailAction: { id in + proxy.scrollTo(id) + }, alignment: self.alignment, showDetailLink: true, bannerMultiMessages: self.$bannerMultiMessages) + .navigationTitle("Edit") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(NSLocalizedString("Save", comment: "")) { + self.validateUploadData() + } + } + } + .onChange(of: self.firstName) { _ in + self.validateUploadData() + } + .onChange(of: self.lastName) { _ in + self.validateUploadData() + } + .onChange(of: self.emailAddress) { _ in + self.validateUploadData() + } + } + } + + var currentDateStr: String { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM dd, YYYY" + return formatter.string(from: Date()) + } + + @State var bannerMultiMessages: [BannerMessageListModel] = [] + + private var alignment: HorizontalAlignment { + switch self.alignmentRawValue { + case 0: + return .leading + case 1: + return .center + default: + return .trailing + } + } + + func validateUploadData() { + var errorMessages: [BannerMessageItemModel] = [] + + var warningMessages: [BannerMessageItemModel] = [] + + var informationMessages: [BannerMessageItemModel] = [] + + if self.firstName.isEmpty { + let tips = "First name is required." + self.firstNameErrorMessage = AttributedString(tips) + warningMessages.append(BannerMessageItemModel(id: self.firstNameId, icon: Image(fioriName: "fiori.warning"), title: tips, messageType: .critical)) + } else if self.firstName.count > 20 { + let tips = "First name is too long." + self.firstNameErrorMessage = AttributedString(tips) + errorMessages.append(BannerMessageItemModel(id: self.firstNameId, icon: Image(fioriName: "fiori.notification.3"), title: tips, messageType: .negative)) + } else { + let tips = "First name correct." + self.firstNameErrorMessage = AttributedString() + informationMessages.append(BannerMessageItemModel(id: self.firstNameId, icon: Image(fioriName: "fiori.hint"), title: tips, messageType: .positive)) + } + + if self.lastName.isEmpty { + let tips = "Last name is required." + self.lastNameErrorMessage = AttributedString(tips) + warningMessages.append(BannerMessageItemModel(id: self.lastNameId, icon: Image(fioriName: "fiori.warning"), title: tips, messageType: .critical)) + } else if self.lastName.count > 20 { + let tips = "Last name is too long." + self.lastNameErrorMessage = AttributedString(tips) + errorMessages.append(BannerMessageItemModel(id: self.lastNameId, icon: Image(fioriName: "fiori.notification.3"), title: tips, messageType: .negative)) + } else { + let tips = "Last name correct." + self.lastNameErrorMessage = AttributedString() + informationMessages.append(BannerMessageItemModel(id: self.lastNameId, icon: Image(fioriName: "fiori.hint"), title: tips, messageType: .positive)) + } + + if self.emailAddress.isEmpty { + let tips = "Email address is required!" + self.emailAddressErrorMessage = AttributedString(tips) + warningMessages.append(BannerMessageItemModel(id: self.emailAddressId, icon: Image(fioriName: "fiori.warning"), title: tips, messageType: .critical)) + } else if self.isEmailInvalid { + let tips = "Email address is Invalid!" + self.emailAddressErrorMessage = AttributedString(tips) + errorMessages.append(BannerMessageItemModel(id: self.emailAddressId, icon: Image(fioriName: "fiori.notification.3"), title: tips, messageType: .negative)) + } else { + let tips = "Email address correct." + self.emailAddressErrorMessage = AttributedString() + informationMessages.append(BannerMessageItemModel(id: self.emailAddressId, icon: Image(fioriName: "fiori.hint"), title: tips, messageType: .positive)) + } + + var result: [BannerMessageListModel] = [] + if !errorMessages.isEmpty { + result.append(BannerMessageListModel(category: "Errors", items: errorMessages)) + } + if !warningMessages.isEmpty { + result.append(BannerMessageListModel(category: "Warnings", items: warningMessages)) + } + if !informationMessages.isEmpty { + result.append(BannerMessageListModel(category: "Information", items: informationMessages)) + } + + self.bannerMultiMessages = result + + self.showBanner = !self.bannerMultiMessages.isEmpty + } + + var isEmailInvalid: Bool { + let emailRegex = Regex { + OneOrMore(.word) + "@" + ChoiceOf { + "sap" + "gmail" + } + ".com" + } + if let _ = emailAddress.wholeMatch(of: emailRegex)?.output { + return false + } + return true + } +} + +#Preview { + BannerMultiMessageExample() +} diff --git a/Apps/Examples/Examples/Info.plist b/Apps/Examples/Examples/Info.plist index c8e674b02..39342bd2f 100644 --- a/Apps/Examples/Examples/Info.plist +++ b/Apps/Examples/Examples/Info.plist @@ -2,6 +2,8 @@ + CFBundleAllowMixedLocalizations + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable diff --git a/Sources/FioriSwiftUICore/Utils/FioriIntrospect.swift b/Sources/FioriSwiftUICore/Utils/FioriIntrospect.swift index f04b8052f..907ccc0a7 100644 --- a/Sources/FioriSwiftUICore/Utils/FioriIntrospect.swift +++ b/Sources/FioriSwiftUICore/Utils/FioriIntrospect.swift @@ -34,9 +34,14 @@ struct FioriIntrospectModifier: ViewModifier { } struct FioriIntrospectionView: UIViewControllerRepresentable { + typealias UIViewControllerType = FioriIntrospectionViewController + + @Binding + private var observed: Void // workaround for state changes not triggering view updates private let introspection: (Target) -> Void init(introspection: @escaping (Target) -> Void) { + self._observed = .constant(()) self.introspection = introspection } @@ -64,10 +69,6 @@ struct FioriIntrospectionView: UIViewControllerRepresent guard let target = context.coordinator.target else { return } self.introspection(target) } - - static func dismantleUIViewController(_ controller: FioriIntrospectionViewController, coordinator: TargetCache) { - controller.handler = nil - } } class FioriIntrospectionViewController: UIViewController { diff --git a/Sources/FioriSwiftUICore/_ComponentProtocols/CompositeComponentProtocols.swift b/Sources/FioriSwiftUICore/_ComponentProtocols/CompositeComponentProtocols.swift index e10a22d66..0ad4b5ff2 100755 --- a/Sources/FioriSwiftUICore/_ComponentProtocols/CompositeComponentProtocols.swift +++ b/Sources/FioriSwiftUICore/_ComponentProtocols/CompositeComponentProtocols.swift @@ -288,6 +288,22 @@ protocol _MenuSelectionComponent: _ActionComponent { protocol _BannerMessageComponent: _IconComponent, _TitleComponent, _CloseActionComponent, _TopDividerComponent { /// The action to be performed when the banner is tapped. var bannerTapAction: (() -> Void)? { get } + + /// The icon and title's `HorizontalAlignment`. The default is `center`. + // sourcery: defaultValue = .center + var alignment: HorizontalAlignment { get } + + /// Hide bottom separator or not. The default is false. + // sourcery: defaultValue = false + var hideSeparator: Bool { get } + + /// The icon and title's type. The default is `neutral`. + // sourcery: defaultValue = .neutral + var messageType: BannerMultiMessageType { get } + + /// Show detail link or not. The default is false. When showDetailLink is true, and click the link will perform to popup the detail sheet. + // sourcery: defaultValue = false + var showDetailLink: Bool { get } } /// `RatingControl` uses images to represent a rating. diff --git a/Sources/FioriSwiftUICore/_FioriStyles/BannerMessageStyle.fiori.swift b/Sources/FioriSwiftUICore/_FioriStyles/BannerMessageStyle.fiori.swift index 7540ae7b5..defb98127 100644 --- a/Sources/FioriSwiftUICore/_FioriStyles/BannerMessageStyle.fiori.swift +++ b/Sources/FioriSwiftUICore/_FioriStyles/BannerMessageStyle.fiori.swift @@ -2,29 +2,83 @@ import FioriThemeManager import Foundation import SwiftUI -// Base Layout style +/// Banner multi message type +public enum BannerMultiMessageType: Int { + // Use this variant when neutral information is provided. Default. Message. + case neutral + // Use this variant when informative message is provided. For example, the status of a user action. + case informative + // Use this variant when a positive message is provided. For example, a confirmation for a successful user action. + case positive + // Use this variant when a critical message is provided. For example, an alert. + case critical + // Use this variant when a negative message is provided. For example, an error. + case negative +} + +/// Base Layout style public struct BannerMessageBaseStyle: BannerMessageStyle { public func makeBody(_ configuration: BannerMessageConfiguration) -> some View { VStack(spacing: 0) { configuration.topDivider.frame(height: 4) HStack { + HStack(spacing: 6, content: { + switch configuration.alignment { + case .leading: + configuration.icon + configuration.title + .padding([.top, .bottom], 13) + Spacer() + case .center: + Spacer() + configuration.icon + configuration.title + .padding([.top, .bottom], 13) + Spacer() + default: + Spacer() + configuration.icon + configuration.title + .padding([.top, .bottom], 13) + } + }) + .padding(.leading, configuration.alignment == .center ? 44 : 16) + .padding(.trailing, configuration.alignment == .center ? 0 : 16) + .onTapGesture { + configuration.bannerTapAction?() + } + Spacer() - configuration.icon - configuration.title - .padding([.top, .bottom], 13) - Spacer() + configuration.closeAction.padding(.trailing) } .frame(minHeight: 39) - Color.preferredColor(.separator).frame(height: 1) + if !configuration.hideSeparator { + Color.preferredColor(.separator).frame(height: 1) + } } .drawingGroup() - .background(.bar) + .background(Color.preferredColor(.tertiaryBackground)) } } // Default fiori styles extension BannerMessageFioriStyle { + static func titleForegroundColor(type: BannerMultiMessageType) -> Color { + switch type { + case .neutral: + Color.preferredColor(.neutralLabel) + case .negative: + Color.preferredColor(.negativeLabel) + case .critical: + Color.preferredColor(.criticalLabel) + case .positive: + Color.preferredColor(.positiveLabel) + case .informative: + Color.preferredColor(.informativeLabel) + } + } + struct ContentFioriStyle: BannerMessageStyle { func makeBody(_ configuration: BannerMessageConfiguration) -> some View { BannerMessage(configuration) @@ -36,8 +90,8 @@ extension BannerMessageFioriStyle { let bannerMessageConfiguration: BannerMessageConfiguration func makeBody(_ configuration: IconConfiguration) -> some View { - Icon(configuration) - .foregroundStyle(Color.preferredColor(.negativeLabel)) + configuration.icon + .foregroundStyle(BannerMessageFioriStyle.titleForegroundColor(type: self.bannerMessageConfiguration.messageType)) } } @@ -45,8 +99,8 @@ extension BannerMessageFioriStyle { let bannerMessageConfiguration: BannerMessageConfiguration func makeBody(_ configuration: TitleConfiguration) -> some View { - Title(configuration) - .foregroundStyle(Color.preferredColor(.negativeLabel)) + configuration.title + .foregroundStyle(BannerMessageFioriStyle.titleForegroundColor(type: self.bannerMessageConfiguration.messageType)) .font(.fiori(forTextStyle: .footnote)) } } @@ -81,13 +135,20 @@ public extension View { pushContentDown: Binding = .constant(false), @ViewBuilder icon: () -> any View = { EmptyView() }, title: AttributedString, - bannerTapped: (() -> Void)? = nil) -> some View + bannerTapped: (() -> Void)? = nil, + alignment: HorizontalAlignment? = nil) -> some View { self.modifier(BannerMessageModifier(icon: icon(), title: Text(title), isPresented: isPresented, pushContentDown: pushContentDown, - bannerTapped: bannerTapped)) + bannerTapped: bannerTapped, + alignment: alignment ?? .center, + hideSeparator: false, + messageType: .neutral, + turnOnSectionHeader: true, + showDetailLink: false, + bannerMultiMessages: Binding<[BannerMessageListModel]>.constant([]))) } /// Show banner message view at the top of target view or as a overlay above the view. @@ -101,13 +162,20 @@ public extension View { pushContentDown: Binding = .constant(false), @ViewBuilder icon: () -> any View = { EmptyView() }, title: String, - bannerTapped: (() -> Void)? = nil) -> some View + bannerTapped: (() -> Void)? = nil, + alignment: HorizontalAlignment? = nil) -> some View { self.modifier(BannerMessageModifier(icon: icon(), title: Text(title), isPresented: isPresented, pushContentDown: pushContentDown, - bannerTapped: bannerTapped)) + bannerTapped: bannerTapped, + alignment: alignment ?? .center, + hideSeparator: false, + messageType: .neutral, + turnOnSectionHeader: true, + showDetailLink: false, + bannerMultiMessages: Binding<[BannerMessageListModel]>.constant([]))) } /// Show banner message view at the top of target view or as a overlay above the view. @@ -116,34 +184,193 @@ public extension View { /// - pushContentDown: A binding to a Boolean value that determines whether push the content down below the banner message. /// - title: A view for the title. /// - bannerTapped: Action for banner tapped. + /// - icon: A view for the icon. + /// - alignment: A alignment for the icon and title /// - Returns: A new `View` with banner message. func bannerMessageView(isPresented: Binding, pushContentDown: Binding = .constant(false), @ViewBuilder icon: () -> any View = { EmptyView() }, @ViewBuilder title: () -> any View, - bannerTapped: (() -> Void)? = nil) -> some View + bannerTapped: (() -> Void)? = nil, + alignment: HorizontalAlignment? = nil) -> some View { self.modifier(BannerMessageModifier(icon: icon(), title: title(), isPresented: isPresented, pushContentDown: pushContentDown, - bannerTapped: bannerTapped)) + bannerTapped: bannerTapped, + alignment: alignment ?? .center, + hideSeparator: false, + messageType: .neutral, + turnOnSectionHeader: true, + showDetailLink: false, + bannerMultiMessages: Binding<[BannerMessageListModel]>.constant([]))) + } + + /// Show banner message view at the top of target view or as a overlay above the view. Click View Detail can pop up message detail sheet. + /// - Parameters: + /// - isPresented: A binding to a Boolean value that determines whether to present the banner message. + /// - pushContentDown: A binding to a Boolean value that determines whether push the content down below the banner message. + /// - icon: A view for the icon. + /// - bannerTapped: Action for banner tapped. + /// - viewDetailAction: View the message detail callback, the parameter is message id, developer can use the id to scroll to the relative item + /// - alignment: A alignment for the icon and title. + /// - hideSeparator: Hide bottom separator or not. + /// - messageType: The type of message, default is .neutral + /// - turnOnSectionHeader: Show message detail section header or not, default is true + /// - showDetailLink: Show view details link or not + /// - bannerMultiMessages: Multi message data array, [BannerMessageListModel] + /// - Returns: A new `View` with banner message. + func bannerMessageView(isPresented: Binding, + pushContentDown: Binding = .constant(false), + @ViewBuilder icon: () -> any View = { EmptyView() }, + bannerTapped: (() -> Void)? = nil, + viewDetailAction: ((UUID) -> Void)? = nil, + alignment: HorizontalAlignment = .center, + hideSeparator: Bool = false, + messageType: BannerMultiMessageType = .neutral, + turnOnSectionHeader: Bool = true, + showDetailLink: Bool = false, + bannerMultiMessages: Binding<[BannerMessageListModel]> = Binding<[BannerMessageListModel]>.constant([])) -> some View + { + var finalMessageType = messageType + for bannerMessageListModel in bannerMultiMessages { + for singleMessageModel in bannerMessageListModel.wrappedValue.items where singleMessageModel.messageType.rawValue > finalMessageType.rawValue { + finalMessageType = singleMessageModel.messageType + } + } + return self.modifier(BannerMessageModifier(icon: icon(), + isPresented: isPresented, + pushContentDown: pushContentDown, + bannerTapped: bannerTapped, + viewDetailAction: viewDetailAction, + alignment: alignment, + hideSeparator: hideSeparator, + messageType: finalMessageType, + turnOnSectionHeader: turnOnSectionHeader, + showDetailLink: showDetailLink, + bannerMultiMessages: bannerMultiMessages)) } } struct BannerMessageModifier: ViewModifier { let icon: any View - let title: any View + var title: (any View)? @Binding var isPresented: Bool @Binding var pushContentDown: Bool var bannerTapped: (() -> Void)? + + // View the message detail callback, the parameter is message id, developer can use the id to scroll to the relative item + var viewDetailAction: ((UUID) -> Void)? + + var alignment: HorizontalAlignment + var hideSeparator: Bool + var messageType: BannerMultiMessageType + var turnOnSectionHeader: Bool + var showDetailLink: Bool @State var offset: CGFloat = 0 + @Binding private var bannerMultiMessages: [BannerMessageListModel] + + @State private var showingMessageDetail: Bool = false + + init(icon: any View, title: (any View)? = nil, isPresented: Binding, pushContentDown: Binding, bannerTapped: (() -> Void)? = nil, viewDetailAction: ((UUID) -> Void)? = nil, alignment: HorizontalAlignment, hideSeparator: Bool, messageType: BannerMultiMessageType, turnOnSectionHeader: Bool, showDetailLink: Bool, bannerMultiMessages: Binding<[BannerMessageListModel]>) { + self.icon = icon + self.title = title + _isPresented = isPresented + _pushContentDown = pushContentDown + self.bannerTapped = bannerTapped + self.viewDetailAction = viewDetailAction + self.alignment = alignment + self.hideSeparator = hideSeparator + self.messageType = messageType + self.turnOnSectionHeader = turnOnSectionHeader + self.showDetailLink = showDetailLink + _bannerMultiMessages = bannerMultiMessages + } + + func calculateMessageSummary() -> AttributedString { + var calculateDict: [BannerMultiMessageType: Int] = [:] + + for BannerMessageListModel in self.bannerMultiMessages { + for singleMessageModel in BannerMessageListModel.items { + let key = singleMessageModel.messageType + if let originValue = calculateDict[key] { + calculateDict[key] = originValue + 1 + } else { + calculateDict[key] = 1 + } + } + } + var summary: [String] = [] + for (key, value) in calculateDict.sorted(by: { $0.key.rawValue > $1.key.rawValue }) { + summary.append("\(value) \(self.messageDesc(type: key, count: value))") + } + + if !summary.isEmpty { + var result = "" + for i in 0 ..< summary.count { + if result == "" { + result = summary[i] + } else { + result += ", \(summary[i])" + } + } + result += ". " + var attributedString = AttributedString(result) + var viewDetail = AttributedString(NSLocalizedString("View Details", tableName: "FioriSwiftUICore", bundle: Bundle.accessor, comment: "")) + viewDetail.foregroundColor = .preferredColor(.tintColor) + viewDetail.link = URL(string: "ViewDetails") + attributedString.append(viewDetail) + return attributedString + } else { + return AttributedString("") + } + } + + func messageDesc(type: BannerMultiMessageType, count: Int) -> String { + var key = "" + switch type { + case .neutral: + key = "message" + case .informative: + key = "information" + case .positive: + key = "confirmation" + case .critical: + key = "warning" + case .negative: + key = "error" + } + return NSLocalizedString(key + (count > 1 ? "s" : ""), tableName: "FioriSwiftUICore", bundle: Bundle.accessor, comment: "") + } + @ViewBuilder var bannerMessage: some View { BannerMessage(icon: { self.icon }, title: { - self.title + if let title = self.title { + AnyView(title) + } else { + Text(self.calculateMessageSummary()) + .environment(\.openURL, OpenURLAction(handler: { url in + if url.absoluteString == "ViewDetails" { + self.showingMessageDetail = true + } + return .handled + })) + .frame(minWidth: (UIDevice.current.userInterfaceIdiom != .phone && self.alignment != .center) ? 393 : nil, alignment: .leading) + .popover(isPresented: self.$showingMessageDetail) { + BannerMultiMessageSheet(closeAction: { + self.showingMessageDetail = false + }, removeAction: { _, _ in + if self.bannerMultiMessages.isEmpty { + self.isPresented = false + } + }, viewDetailAction: self.viewDetailAction, turnOnSectionHeader: self.turnOnSectionHeader, bannerMultiMessages: self.$bannerMultiMessages) + .presentationDetents([.medium, .large]) + } + } }, closeAction: { FioriButton { state in if state == .normal { @@ -152,17 +379,14 @@ struct BannerMessageModifier: ViewModifier { } } } label: { _ in - Image(systemName: "xmark") + Image(fioriName: "fiori.decline") } - }) - .onTapGesture { - self.bannerTapped?() - } - .sizeReader { size in - if abs(self.offset + size.height) > 0.1 { - self.offset = -size.height + }, bannerTapAction: self.bannerTapped, alignment: self.alignment, hideSeparator: self.hideSeparator, messageType: self.messageType, showDetailLink: self.showDetailLink) + .sizeReader { size in + if abs(self.offset + size.height) > 0.1, !self.showingMessageDetail { + self.offset = -size.height + } } - } } func body(content: Content) -> some View { diff --git a/Sources/FioriSwiftUICore/_FioriStyles/BannerMultiMessageSheet.fiori.swift b/Sources/FioriSwiftUICore/_FioriStyles/BannerMultiMessageSheet.fiori.swift new file mode 100644 index 000000000..e6096bf2b --- /dev/null +++ b/Sources/FioriSwiftUICore/_FioriStyles/BannerMultiMessageSheet.fiori.swift @@ -0,0 +1,376 @@ +import Combine +import FioriThemeManager +import SwiftUI + +/// Single Banner Message Model +public struct BannerMessageItemModel: Identifiable { + public var id: UUID + /// Banner icon + public var icon: any View + /// Banner title + public var title: String + /// Message Type + public var messageType: BannerMultiMessageType + + public var typeDesc: String { + switch self.messageType { + case .neutral: + NSLocalizedString("message", tableName: "FioriSwiftUICore", bundle: Bundle.accessor, comment: "") + case .informative: + NSLocalizedString("information", tableName: "FioriSwiftUICore", bundle: Bundle.accessor, comment: "") + case .positive: + NSLocalizedString("confirmation", tableName: "FioriSwiftUICore", bundle: Bundle.accessor, comment: "") + case .critical: + NSLocalizedString("warning", tableName: "FioriSwiftUICore", bundle: Bundle.accessor, comment: "") + case .negative: + NSLocalizedString("error", tableName: "FioriSwiftUICore", bundle: Bundle.accessor, comment: "") + } + } + + public init(id: UUID = UUID(), icon: any View, title: String, messageType: BannerMultiMessageType) { + self.id = id + self.icon = icon + self.title = title + self.messageType = messageType + } +} + +public struct BannerMessageListModel: Identifiable, Equatable { + public static func == (lhs: BannerMessageListModel, rhs: BannerMessageListModel) -> Bool { + lhs.id == rhs.id + } + + public var id: UUID + // customized category, like "Errors", "Warnings", "Information", etc + public let category: String + public var items: [BannerMessageItemModel] + + /// Public initializer for BannerMessageListModel + /// - Parameters: + /// - id: the identification for the category + /// - category: category name + /// - items: the list under the category + public init(id: UUID = UUID(), category: String, items: [BannerMessageItemModel]) { + self.id = id + self.category = category + self.items = items + } +} + +class CategorySelect: ObservableObject { + @Published var categorySelectedIndex = 0 + + init(categorySelectedIndex: Int = 0) { + self.categorySelectedIndex = categorySelectedIndex + } +} + +public struct BannerMultiMessageSheet: View { + // The callback when click the close button. + private var closeAction: (() -> Void)? = nil + // Remove item action, First parameter is category, and the secondary is the item's id. When the secondary is nil, the entire category was removed. + private var removeAction: ((String, UUID?) -> Void)? = nil + // View the message detail callback, the parameter is message id, developer can use the id to scroll to the relative item + private var viewDetailAction: ((UUID) -> Void)? = nil + // Turn on category section header or not + private var turnOnSectionHeader = true + + @Binding private var bannerMultiMessages: [BannerMessageListModel] + + @StateObject private var categorySelect = CategorySelect() + @State private var dimensionSelector: DimensionSelector = { + let all = NSLocalizedString("All", tableName: "FioriSwiftUICore", bundle: Bundle.accessor, comment: "") + return DimensionSelector(segmentTitles: [all], selectedIndex: 0) + }() + + @State private var timer: Timer? + @State private var cancellableSet: Set = [] + + /// Public initializer for banner multi-message sheet + /// - Parameters: + /// - closeAction: callback when close button is clicked + /// - removeAction: callback when category or single item is removed + /// - viewDetailAction: callback when the link button is clicked + /// - turnOnSectionHeader: the mark to turn on section header or not + /// - bannerMultiMessages: the data source for banner multi-message sheet + public init(closeAction: (() -> Void)? = nil, + removeAction: ((String, UUID?) -> Void)? = nil, + viewDetailAction: ((UUID) -> Void)? = nil, + turnOnSectionHeader: Bool = true, + bannerMultiMessages: Binding<[BannerMessageListModel]>) + { + self.closeAction = closeAction + self.removeAction = removeAction + self.viewDetailAction = viewDetailAction + self.turnOnSectionHeader = turnOnSectionHeader + + _bannerMultiMessages = bannerMultiMessages + + self.resetDimensionSelector() + } + + private var messageItemView: ((UUID) -> any View)? = nil + + /// Public initializer for banner multi-message sheet + /// - Parameters: + /// - closeAction: callback when close button is clicked + /// - removeAction: callback when category or single item is removed + /// - turnOnSectionHeader: the mark to turn on section header or not + /// - bannerMultiMessages: the data source for banner multi-message sheet + /// - messageItemView: view for each item under the category + public init(closeAction: (() -> Void)? = nil, + removeAction: ((String, UUID?) -> Void)? = nil, + turnOnSectionHeader: Bool = true, + bannerMultiMessages: Binding<[BannerMessageListModel]>, + @ViewBuilder messageItemView: @escaping ((UUID) -> any View)) + { + self.closeAction = closeAction + self.removeAction = removeAction + self.turnOnSectionHeader = turnOnSectionHeader + _bannerMultiMessages = bannerMultiMessages + self.messageItemView = messageItemView + + self.resetDimensionSelector() + } + + private func resetDimensionSelector() { + var titles: [String] = [] + for element in self.bannerMultiMessages { + titles.append(element.category) + } + let all = NSLocalizedString("All", tableName: "FioriSwiftUICore", bundle: Bundle.accessor, comment: "") + self.dimensionSelector.titles = [all] + titles + self.dimensionSelector.selectedIndex = 0 + } + + // List in popover will not expand automatically in iPad. Here, calculate the content height and resize its frame's height, the maximum of the popover height in iPad is 380. + @State private var scrollContentHeight: CGFloat = 40 + @State private var dimensionSelectorHeight: CGFloat = 0 + @State private var messageCountHeight: CGFloat = 65 + private var popoverHeight: CGFloat? { + let contentHeight = self.messageCountHeight + self.dimensionSelectorHeight + self.scrollContentHeight + return !self.isPhone ? min(contentHeight, 380.0) : nil + } + + private var isPhone: Bool { + UIDevice.current.userInterfaceIdiom == .phone + } + + private var filteredBannerMultiMessages: [BannerMessageListModel] { + let selectedCategory = self.dimensionSelector.titles[self.categorySelect.categorySelectedIndex] + let filteredBannerMultiMessages = self.bannerMultiMessages.filter { model in + if self.categorySelect.categorySelectedIndex == 0 { + return true + } else { + return model.category == selectedCategory + } + } + return filteredBannerMultiMessages + } + + private var messageCountStr: String { + var count = 0 + for element in self.bannerMultiMessages { + count += element.items.count + } + return String(format: NSLocalizedString("Messages (%d)", tableName: "FioriSwiftUICore", bundle: Bundle.accessor, comment: ""), count) + } + + public var body: some View { + VStack(spacing: 0, content: { + HStack { + Text(self.messageCountStr) + .foregroundStyle(Color.preferredColor(.primaryLabel)) + .font(.fiori(forTextStyle: .headline, weight: .bold)) + + if self.isPhone { + Spacer() + + FioriButton(isSelectionPersistent: false, action: { _ in + self.dismiss() + }, image: { _ in + Image(fioriName: "fiori.error") + }) + .fioriButtonStyle(FioriTertiaryButtonStyle(colorStyle: .normal)) + } + } + .padding(.leading, self.isPhone ? 16 : 0) + .padding(.top, 27) + .padding(.bottom, 16) + .sizeReader { size in + self.messageCountHeight = size.height + } + + self.dimensionSelector + .sizeReader { size in + self.dimensionSelectorHeight = size.height + } + + List { + ForEach(self.filteredBannerMultiMessages, id: \.id) { element in + Section { + if self.turnOnSectionHeader { + HStack { + Text("\(element.category) (\(element.items.count))") + .font(.fiori(forTextStyle: .subheadline)) + .foregroundStyle(Color.preferredColor(.secondaryLabel)) + Spacer() + + _Action(actionText: _ClearActionDefault().actionText, didSelectAction: { + self.removeCategoryAction(category: element.category) + }) + .font(.fiori(forTextStyle: .subheadline)) + .foregroundStyle(Color.preferredColor(.tintColor)) + } + .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + + ForEach(0 ..< element.items.count, id: \.self) { index in + let message = element.items[index] + + if let item = self.messageItemView { + AnyView(item(message.id)) + } else { + BannerMessage(icon: { + message.icon + }, title: { + Text(self.attributedMessageTitle(title: message.title, typeDesc: message.typeDesc)) + }, closeAction: { + FioriButton { state in + if state == .normal { + self.removeItem(category: element.category, at: message.id) + } + } label: { _ in + Image(fioriName: "fiori.decline") + } + }, topDivider: { + EmptyView() + }, bannerTapAction: { + self.showItemDetail(category: element.category, at: message.id) + }, alignment: .leading, hideSeparator: true, messageType: message.messageType) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + self.removeItem(category: element.category, at: message.id) + } label: { + Image(fioriName: "fiori.delete") + } + } + } + } + + } footer: { + if self.turnOnSectionHeader { + Rectangle().fill(Color.preferredColor(.primaryGroupedBackground)) + .frame(height: 30) + } + } + .listSectionSeparator(self.turnOnSectionHeader ? .hidden : .visible, edges: .bottom) + .listRowInsets(EdgeInsets()) + .alignmentGuide(.listRowSeparatorLeading, computeValue: { _ in + 0 + }) + } + } + .background(Color.preferredColor(.primaryGroupedBackground)) + .listStyle(.plain) + .environment(\.defaultMinListRowHeight, 0) + .environment(\.defaultMinListHeaderHeight, 0) + .modifier(FioriIntrospectModifier { collectionView in + DispatchQueue.main.async { + if collectionView.contentSize.height != self.scrollContentHeight, !self.isPhone { + self.scrollContentHeight = collectionView.contentSize.height + } + } + }) + }) + .background(Color.preferredColor(.chrome)) + .onDisappear(perform: { + self.timer?.invalidate() + self.timer = nil + }) + .frame(minWidth: !self.isPhone ? 393 : nil) + .frame(height: self.popoverHeight) + .animation(self.scrollContentHeight <= 40.0 ? nil : .spring) +// .animation(.spring, value: self.popoverHeight) + .onChange(of: self.bannerMultiMessages) { _ in + // when datasource is empty, dismiss in 2 seconds + if self.bannerMultiMessages.isEmpty { + self.timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false, block: { _ in + self.dismiss() + }) + } + self.resetDimensionSelector() + } + .onAppear { + self.dimensionSelector.selectionDidChangePublisher + .sink(receiveValue: { index in + self.categorySelect.categorySelectedIndex = index ?? 0 + }) + .store(in: &self.cancellableSet) + } + } + + private func attributedMessageTitle(title: String, typeDesc: String) -> AttributedString { + let attributedString = NSMutableAttributedString(string: title) + + let viewDetailStr = String(format: NSLocalizedString("View %@", tableName: "FioriSwiftUICore", bundle: Bundle.accessor, comment: ""), typeDesc) + let viewDetail = NSAttributedString(string: " \(viewDetailStr)", attributes: [.foregroundColor: UIColor(Color.preferredColor(.tintColor))]) + attributedString.append(viewDetail) + return AttributedString(attributedString) + } + + private func removeItem(category: String, at id: UUID) { + for i in 0 ..< self.bannerMultiMessages.count { + var element = self.bannerMultiMessages[i] + if element.category == category { + for index in 0 ..< element.items.count where element.items[index].id == id { + element.items.remove(at: index) + break + } + self.bannerMultiMessages.remove(at: i) + self.bannerMultiMessages.insert(element, at: i) + + if element.items.isEmpty { + self.handleRemoveCategory(category) + } + break + } + } + self.removeAction?(category, id) + } + + private func removeCategoryAction(category: String) { + self.handleRemoveCategory(category) + self.removeAction?(category, nil) + } + + private func handleRemoveCategory(_ category: String) { + for i in 0 ..< self.bannerMultiMessages.count { + let element = self.bannerMultiMessages[i] + if element.category == category { + self.bannerMultiMessages.remove(at: i) + break + } + } + } + + private func showItemDetail(category: String, at id: UUID) { + self.viewDetailAction?(id) + self.dismiss() + } + + private func dismiss() { + self.timer?.invalidate() + self.timer = nil + + self.closeAction?() + } +} + +#Preview { + BannerMultiMessageSheet(bannerMultiMessages: Binding<[BannerMessageListModel]>.constant([ + BannerMessageListModel(category: "Errors", items: [ + BannerMessageItemModel(icon: Image(fioriName: "fiori.notification.3"), title: "Single-line text for banner.", messageType: .negative) + ]) + ])) +} diff --git a/Sources/FioriSwiftUICore/_generated/StyleableComponents/BannerMessage/BannerMessage.generated.swift b/Sources/FioriSwiftUICore/_generated/StyleableComponents/BannerMessage/BannerMessage.generated.swift index 4a2bbeb58..b99aa0da1 100644 --- a/Sources/FioriSwiftUICore/_generated/StyleableComponents/BannerMessage/BannerMessage.generated.swift +++ b/Sources/FioriSwiftUICore/_generated/StyleableComponents/BannerMessage/BannerMessage.generated.swift @@ -10,6 +10,14 @@ public struct BannerMessage { let topDivider: any View /// The action to be performed when the banner is tapped. let bannerTapAction: (() -> Void)? + /// The icon and title's `HorizontalAlignment`. The default is `center`. + let alignment: HorizontalAlignment + /// Hide bottom separator or not. The default is false. + let hideSeparator: Bool + /// The icon and title's type. The default is `neutral`. + let messageType: BannerMultiMessageType + /// Show detail link or not. The default is false. When showDetailLink is true, and click the link will perform to popup the detail sheet. + let showDetailLink: Bool @Environment(\.bannerMessageStyle) var style @@ -19,13 +27,21 @@ public struct BannerMessage { @ViewBuilder title: () -> any View, @ViewBuilder closeAction: () -> any View = { FioriButton { _ in Image(systemName: "xmark") } }, @ViewBuilder topDivider: () -> any View = { Rectangle().fill(Color.clear) }, - bannerTapAction: (() -> Void)? = nil) + bannerTapAction: (() -> Void)? = nil, + alignment: HorizontalAlignment = .center, + hideSeparator: Bool = false, + messageType: BannerMultiMessageType = .neutral, + showDetailLink: Bool = false) { self.icon = Icon(icon: icon) self.title = Title(title: title) self.closeAction = CloseAction(closeAction: closeAction) self.topDivider = TopDivider(topDivider: topDivider) self.bannerTapAction = bannerTapAction + self.alignment = alignment + self.hideSeparator = hideSeparator + self.messageType = messageType + self.showDetailLink = showDetailLink } } @@ -34,9 +50,13 @@ public extension BannerMessage { title: AttributedString, closeAction: FioriButton? = FioriButton { _ in Image(systemName: "xmark") }, @ViewBuilder topDivider: () -> any View = { Rectangle().fill(Color.clear) }, - bannerTapAction: (() -> Void)? = nil) + bannerTapAction: (() -> Void)? = nil, + alignment: HorizontalAlignment = .center, + hideSeparator: Bool = false, + messageType: BannerMultiMessageType = .neutral, + showDetailLink: Bool = false) { - self.init(icon: { icon }, title: { Text(title) }, closeAction: { closeAction }, topDivider: topDivider, bannerTapAction: bannerTapAction) + self.init(icon: { icon }, title: { Text(title) }, closeAction: { closeAction }, topDivider: topDivider, bannerTapAction: bannerTapAction, alignment: alignment, hideSeparator: hideSeparator, messageType: messageType, showDetailLink: showDetailLink) } } @@ -51,6 +71,10 @@ public extension BannerMessage { self.closeAction = configuration.closeAction self.topDivider = configuration.topDivider self.bannerTapAction = configuration.bannerTapAction + self.alignment = configuration.alignment + self.hideSeparator = configuration.hideSeparator + self.messageType = configuration.messageType + self.showDetailLink = configuration.showDetailLink self._shouldApplyDefaultStyle = shouldApplyDefaultStyle } } @@ -60,7 +84,7 @@ extension BannerMessage: View { if self._shouldApplyDefaultStyle { self.defaultStyle() } else { - self.style.resolve(configuration: .init(icon: .init(self.icon), title: .init(self.title), closeAction: .init(self.closeAction), topDivider: .init(self.topDivider), bannerTapAction: self.bannerTapAction)).typeErased + self.style.resolve(configuration: .init(icon: .init(self.icon), title: .init(self.title), closeAction: .init(self.closeAction), topDivider: .init(self.topDivider), bannerTapAction: self.bannerTapAction, alignment: self.alignment, hideSeparator: self.hideSeparator, messageType: self.messageType, showDetailLink: self.showDetailLink)).typeErased .transformEnvironment(\.bannerMessageStyleStack) { stack in if !stack.isEmpty { stack.removeLast() @@ -78,7 +102,7 @@ private extension BannerMessage { } func defaultStyle() -> some View { - BannerMessage(.init(icon: .init(self.icon), title: .init(self.title), closeAction: .init(self.closeAction), topDivider: .init(self.topDivider), bannerTapAction: self.bannerTapAction)) + BannerMessage(.init(icon: .init(self.icon), title: .init(self.title), closeAction: .init(self.closeAction), topDivider: .init(self.topDivider), bannerTapAction: self.bannerTapAction, alignment: self.alignment, hideSeparator: self.hideSeparator, messageType: self.messageType, showDetailLink: self.showDetailLink)) .shouldApplyDefaultStyle(false) .bannerMessageStyle(BannerMessageFioriStyle.ContentFioriStyle()) .typeErased diff --git a/Sources/FioriSwiftUICore/_generated/StyleableComponents/BannerMessage/BannerMessageStyle.generated.swift b/Sources/FioriSwiftUICore/_generated/StyleableComponents/BannerMessage/BannerMessageStyle.generated.swift index c7637a7a5..c79fed163 100644 --- a/Sources/FioriSwiftUICore/_generated/StyleableComponents/BannerMessage/BannerMessageStyle.generated.swift +++ b/Sources/FioriSwiftUICore/_generated/StyleableComponents/BannerMessage/BannerMessageStyle.generated.swift @@ -27,6 +27,10 @@ public struct BannerMessageConfiguration { public let closeAction: CloseAction public let topDivider: TopDivider public let bannerTapAction: (() -> Void)? + public let alignment: HorizontalAlignment + public let hideSeparator: Bool + public let messageType: BannerMultiMessageType + public let showDetailLink: Bool public typealias Icon = ConfigurationViewWrapper public typealias Title = ConfigurationViewWrapper diff --git a/Sources/FioriSwiftUICore/_localization/en.lproj/FioriSwiftUICore.strings b/Sources/FioriSwiftUICore/_localization/en.lproj/FioriSwiftUICore.strings index 5c12484b2..9bafbfb20 100644 --- a/Sources/FioriSwiftUICore/_localization/en.lproj/FioriSwiftUICore.strings +++ b/Sources/FioriSwiftUICore/_localization/en.lproj/FioriSwiftUICore.strings @@ -216,3 +216,37 @@ /* XACT: read-only RatingControl accessibility label: average rating with review count over ceiling */ "Rating Control, average rating %.1f of %d, %d plus reviews" = "Rating Control, average rating %.1f of %d, %d plus reviews"; + +/* XBUT: View all messages in message banner sheet */ +"All" = "All"; + +/* XBUT: messages count in the message banner sheet */ +"Messages (%d)" = "Messages (%d)"; + +/* XBUT: view details in the message banner*/ +"View Details" = "View Details"; + +/* XBUT: view different message in the message banner sheet item*/ +"View %@" = "View %@"; + +/* XBUT: banner message type desc, message */ +"message" = "message"; +/* XBUT: banner message type desc, information */ +"information" = "information"; +/* XBUT: banner message type desc, confirmation*/ +"confirmation" = "confirmation"; +/* XBUT: banner message type desc, warning */ +"warning" = "warning"; +/* XBUT: banner message type desc, error */ +"error" = "error"; + +/* XBUT: banner message type desc in plural form, messages */ +"messages" = "messages"; +/* XBUT: banner message type desc in plural form, informations */ +"informations" = "informations"; +/* XBUT: banner message type desc in plural form, confirmations */ +"confirmations" = "confirmations"; +/* XBUT: banner message type desc in plural form, warnings */ +"warnings" = "warnings"; +/* XBUT: banner message type desc in plural form, errors */ +"errors" = "errors";