diff --git a/enzevalos_iphone.xcodeproj/project.pbxproj b/enzevalos_iphone.xcodeproj/project.pbxproj index c20306465609ef9893312ec10e3db08c90a944ad..d92a5563fd076aab49a70cdb343a495d1cbaf739 100644 --- a/enzevalos_iphone.xcodeproj/project.pbxproj +++ b/enzevalos_iphone.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ 3FB75DC525FFA75C00919925 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB75DC425FFA75B00919925 /* ComposeView.swift */; }; 3FB75DC925FFA77800919925 /* RecipientRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB75DC825FFA77800919925 /* RecipientRowView.swift */; }; 3FB75DCD25FFD37400919925 /* RecipientListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB75DCC25FFD37400919925 /* RecipientListView.swift */; }; + 3FEFF41E260A899100A7F9CC /* ComposeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FEFF41D260A899100A7F9CC /* ComposeHeaderView.swift */; }; 4705E31A25AE36710065FF90 /* SMIMEMailTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4705E31925AE36710065FF90 /* SMIMEMailTest.swift */; }; 4706D65F225B7B6B00B3F1D3 /* ItunesHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4706D65E225B7B6B00B3F1D3 /* ItunesHandler.swift */; }; 4706D661225CD21D00B3F1D3 /* ExportKeyHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4706D660225CD21D00B3F1D3 /* ExportKeyHelper.swift */; }; @@ -304,6 +305,7 @@ 3FB75DC425FFA75B00919925 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; }; 3FB75DC825FFA77800919925 /* RecipientRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipientRowView.swift; sourceTree = "<group>"; }; 3FB75DCC25FFD37400919925 /* RecipientListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecipientListView.swift; sourceTree = "<group>"; }; + 3FEFF41D260A899100A7F9CC /* ComposeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeHeaderView.swift; sourceTree = "<group>"; }; 434D0E988CDFB6D2D46C1EA8 /* Pods_enzevalos_iphoneUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_enzevalos_iphoneUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4705E31925AE36710065FF90 /* SMIMEMailTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMIMEMailTest.swift; sourceTree = "<group>"; }; 4706D65E225B7B6B00B3F1D3 /* ItunesHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItunesHandler.swift; sourceTree = "<group>"; }; @@ -1230,6 +1232,7 @@ 3FB75DCC25FFD37400919925 /* RecipientListView.swift */, 47E04DDE255D3CF600189320 /* ComposeModel.swift */, 3FB75DC425FFA75B00919925 /* ComposeView.swift */, + 3FEFF41D260A899100A7F9CC /* ComposeHeaderView.swift */, ); path = Compose; sourceTree = "<group>"; @@ -1908,6 +1911,7 @@ 97BDE0432429188500B0BF03 /* BadgeProgressView.swift in Sources */, 47C822682438A85C005BCE73 /* SenderDetails.swift in Sources */, 47C8225824379EAE005BCE73 /* FloatingActionButton.swift in Sources */, + 3FEFF41E260A899100A7F9CC /* ComposeHeaderView.swift in Sources */, 47B71AAD2538354A00CA87C6 /* NewOnboardingView.swift in Sources */, AD97DFBE241F97A300C35B95 /* OnboardingIntroInfoSection.swift in Sources */, 47BCAF40259CFE110008FE4B /* AddButton.swift in Sources */, diff --git a/enzevalos_iphone/SwiftUI/Compose/ComposeHeaderView.swift b/enzevalos_iphone/SwiftUI/Compose/ComposeHeaderView.swift new file mode 100644 index 0000000000000000000000000000000000000000..3b2529e2ad6bb5db8f3f447ff07fcd30e483f2c5 --- /dev/null +++ b/enzevalos_iphone/SwiftUI/Compose/ComposeHeaderView.swift @@ -0,0 +1,89 @@ +// +// ComposeHeaderView.swift +// enzevalos_iphone +// +// Created by Chris Offner on 23.03.21. +// Copyright © 2021 fu-berlin. All rights reserved. +// + +import SwiftUI + +/// A view that contains the Cancel and Send buttons for an email. +struct ComposeViewHeader: View { + @Environment(\.presentationMode) var presentationMode + @ObservedObject var model: ComposeModel + @State private var encryptionOn = true + + var body: some View { + VStack { + // Grab handle in top center + Capsule() + .fill(Color(.lightGray)) + .frame(width: 70, height: 5) + + ZStack { + HStack { + // Cancel button + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + + Spacer() + + Button("Send") { + model.sendMail() + presentationMode.wrappedValue.dismiss() + } + .disabled(model.recipientsModel.hasNoRecipients) + } + + Toggle("", isOn: $encryptionOn) + .toggleStyle(EncryptionToggleStyle()) + .labelsHidden() + } + } + } + + /// Custom styling for the encryption toggle in ComposeViewHeader. + struct EncryptionToggleStyle: ToggleStyle { + func makeBody(configuration: Configuration) -> some View { + Button { + configuration.isOn.toggle() + } label: { + // Separate labels required for desired scaling transition between states + if configuration.isOn { + encryptedButtonLabel + } else { + unencryptedButtonLabel + } + } + .frame(width: 40, height: 40) + } + + /// Label style for encryption button when encryption is activated. + private var encryptedButtonLabel: some View { + ZStack { + Circle() + .stroke(Color.blue, lineWidth: 2) + + Image(systemName: "lock.fill") + .font(Font.system(size: 24, weight: Font.Weight.light)) + .foregroundColor(.blue) + } + .transition(AnyTransition.opacity.combined(with: .scale)) + } + + /// Label style for encryption button when encryption is deactivated. + private var unencryptedButtonLabel: some View { + ZStack { + Circle() + .fill(Color(UIColor.tertiaryLabel)) + + Image(systemName: "lock.slash.fill") + .font(Font.system(size: 24, weight: Font.Weight.light)) + .foregroundColor(.white) + } + .transition(AnyTransition.opacity.combined(with: .scale)) + } + } +} diff --git a/enzevalos_iphone/SwiftUI/Compose/ComposeModel.swift b/enzevalos_iphone/SwiftUI/Compose/ComposeModel.swift index 1b8c21aee6ae7fb75972f9b7973e624139ecca64..bb110e28e097732793ce0a540d7c44e916e70b29 100644 --- a/enzevalos_iphone/SwiftUI/Compose/ComposeModel.swift +++ b/enzevalos_iphone/SwiftUI/Compose/ComposeModel.swift @@ -13,7 +13,7 @@ class ComposeModel: ObservableObject { @Published var subject = "" @Published var body = "" @Published var encryptionOff = false - var recipientsModel: RecipientsModel = RecipientsModel() + @Published var recipientsModel: RecipientsModel = RecipientsModel() init(preData: PreMailData?) { if let preData = preData { @@ -23,6 +23,7 @@ class ComposeModel: ObservableObject { addAddresses(preData.cc, model: recipientsModel.ccModel) addAddresses(preData.bcc, model: recipientsModel.bccModel) } + recipientsModel.parentComposeModel = self } // TODO: Add security state diff --git a/enzevalos_iphone/SwiftUI/Compose/ComposeView.swift b/enzevalos_iphone/SwiftUI/Compose/ComposeView.swift index 74b57b46d8b316d101cbc151662ce45735583d8b..a11b1b49f8f7fb0b918c407f7b772f2bda8b43b4 100644 --- a/enzevalos_iphone/SwiftUI/Compose/ComposeView.swift +++ b/enzevalos_iphone/SwiftUI/Compose/ComposeView.swift @@ -20,13 +20,13 @@ struct ComposeView: View { var body: some View { VStack { // Top bar with Cancel and Send button - ComposeViewHeader() - .environmentObject(model) + ComposeViewHeader(model: model) Divider() // "To" recipients RecipientField(model: model.recipientsModel.toModel) + Divider() // "Cc/Bcc" recipients @@ -38,7 +38,6 @@ struct ComposeView: View { .foregroundColor(Color(UIColor.tertiaryLabel)) TextField("", text: $model.subject) .autocapitalization(.none) - .frame(minWidth: 0, maxWidth: .infinity) } Divider() @@ -47,90 +46,7 @@ struct ComposeView: View { TextEditor(text: $model.body) } .padding() - .animation(.default) - } -} - -/// A view that contains the Cancel and Send buttons for an email. -struct ComposeViewHeader: View { - @Environment(\.presentationMode) var presentationMode - @EnvironmentObject var model: ComposeModel - @State var encryptionOn = true - - var body: some View { - VStack { - // Grab handle in top center - Capsule() - .fill(Color(.lightGray)) - .frame(width: 70, height: 5) - - HStack { - // Cancel button - Button("Cancel") { - presentationMode.wrappedValue.dismiss() - } - - Spacer() - - // Toggle("Use Encryption", isOn: $encryptionOn) - // .foregroundColor(.accentColor) - // .labelsHidden() - // .position(x: geometry.size.width / 3) - // - // Spacer() - - // Send button - Button { - model.sendMail() - presentationMode.wrappedValue.dismiss() - } label: { - if model.encryptionOff { - UnencryptedSendButton(grayedOut: model.recipientsModel.hasNoRecipients) - } else { - EncryptedSendButton(grayedOut: model.recipientsModel.hasNoRecipients) - } - } - .disabled(model.recipientsModel.hasNoRecipients) - } - } - } -} - -/// Styling for Send button if email gets sent encrypted. -struct EncryptedSendButton: View { - var grayedOut: Bool - - var body: some View { - HStack { - Image(systemName: "lock") - .foregroundColor(Color.white) - Spacer() - Text("Send") - .foregroundColor(Color.white) - } - .padding(.horizontal) - .padding(.vertical, 5) - .background(Capsule().fill(grayedOut ? Color(UIColor.tertiaryLabel) : Color.blue)) - .frame(width: 101, height: 40) - } -} - -/// Styling for Send button if email gets sent unencrypted. -struct UnencryptedSendButton: View { - var grayedOut: Bool - - var body: some View { - HStack { - Image(systemName: "lock.open") - .foregroundColor(Color.blue) - Spacer() - Text("Send") - .foregroundColor(Color.blue) - } - .padding(.horizontal) - .padding(.vertical, 5) - .background(Capsule().stroke()) - .frame(width: 101, height: 40) + .animation(.easeInOut) } } @@ -169,7 +85,6 @@ struct RecipientField: View { if showList || !model.suggestions.isEmpty { Divider() RecipientListView() - .frame(maxHeight: .infinity) .environmentObject(model) } } @@ -189,12 +104,17 @@ struct RecipientField: View { .firstIndex(of: recipient) } } + .padding(2) } /// A TextField into which new recipients can be entered. var NewRecipientTextField: some View { TextField("", text: $model.text) { isEditing in - model.parent?.isEditingCcOrBcc = isEditing + if model.type != .to, + let parent = model.parentRecipientModel { + parent.isEditingCcOrBcc = isEditing + } + } onCommit: { model.commitText() // TODO: Fix bug on first Cc or Bcc recipient commit @@ -237,20 +157,32 @@ struct RecipientCapsule: View { @Binding var indexOfSelected: Int? var body: some View { - HStack { - Text(name) - - if isSelected { - Button(action: removeRecipient) { - Image(systemName: "xmark") - .font(.caption2) + ZStack { + Capsule() + .stroke(isSelected ? Color.accentColor : .secondary, + lineWidth: isSelected ? 2 : 1) + + HStack { + Text(name) + .fixedSize(horizontal: true, vertical: false) + + // Deletion button shown if recipient capsule is selected + if isSelected { + Button(action: removeRecipient) { + Image(systemName: "xmark") + } + .transition(.asymmetric( + insertion: AnyTransition + .opacity.combined(with: .slide), + removal: AnyTransition + .opacity.combined(with: .move(edge: .leading))) + ) } } + .padding(.horizontal, 8) + .padding(.vertical, 2) + .foregroundColor(isSelected ? .accentColor : .secondary) } - .foregroundColor(isSelected ? .white : .accentColor) - .padding(.horizontal, 12) - .padding(.vertical, 2) - .background(Capsule().fill(isSelected ? Color.accentColor : Color(UIColor.tertiaryLabel))) } // TODO: Use actual displayname once it's implemented. diff --git a/enzevalos_iphone/SwiftUI/Compose/RecipientFieldModel.swift b/enzevalos_iphone/SwiftUI/Compose/RecipientFieldModel.swift index dc4906c77e0084a5bc5b39071cf50527fd87b976..55f926d1501af9a7f52d7ba50725c9af6da36c1f 100644 --- a/enzevalos_iphone/SwiftUI/Compose/RecipientFieldModel.swift +++ b/enzevalos_iphone/SwiftUI/Compose/RecipientFieldModel.swift @@ -12,10 +12,10 @@ import SwiftUI /// A model for a single recipient field. class RecipientFieldModel: ObservableObject { @Published var suggestions = [AddressRecord]() - @Published var selectedContacts = [AddressRecord]() + @Published var selectedContacts = [AddressRecord]() @Published var type: RecipientType private var dataprovider: PersistentDataProvider - var parent: RecipientsModel? + var parentRecipientModel: RecipientsModel? init(type: RecipientType, dataprovider: PersistentDataProvider) { self.type = type @@ -79,6 +79,13 @@ class RecipientFieldModel: ObservableObject { selectedContacts.append(addr) text = "" suggestions = [] + + // TODO: Find better way to manage state and UI updates! + // This is a horrible hack to get UI to refresh for Send button enable/disable state. + // Please fix wherever you find the following line of code. May require architecture + // changes because state changes (currently ) don't propagate through nested models. + // Read: https://rhonabwy.com/2021/02/13/nested-observable-objects-in-swiftui/ + parentRecipientModel?.parentComposeModel?.subject += "" } /// Removes contact at index from recipients. @@ -88,6 +95,7 @@ class RecipientFieldModel: ObservableObject { } else { print("ERROR wrong index! \(index) but \(selectedContacts.count)") } + parentRecipientModel?.parentComposeModel?.subject += "" } /// Commits text to addresses. @@ -96,7 +104,7 @@ class RecipientFieldModel: ObservableObject { addNewAddress(text) text = "" } - print(selectedContacts.count) + parentRecipientModel?.parentComposeModel?.subject += "" } func addNewAddress(_ address: String) { diff --git a/enzevalos_iphone/SwiftUI/Compose/RecipientRowView.swift b/enzevalos_iphone/SwiftUI/Compose/RecipientRowView.swift index 64bff66670d5d543d94733c2c9c0ab836da52da5..00986220394935fad58f9c0098c4e35a559c195e 100644 --- a/enzevalos_iphone/SwiftUI/Compose/RecipientRowView.swift +++ b/enzevalos_iphone/SwiftUI/Compose/RecipientRowView.swift @@ -13,48 +13,49 @@ struct RecipientRowView: View { @EnvironmentObject var model: RecipientFieldModel var body: some View { - HStack { - // Profile picture - contact.avatar - .resizable() - .frame(width: 30, height: 30) - .aspectRatio(contentMode: .fit) - .clipShape(/*@START_MENU_TOKEN@*/Circle()/*@END_MENU_TOKEN@*/) - .shadow(radius: 3) - .padding(.trailing, 8) - - VStack(alignment: .leading) { - HStack { - // TODO: Show proper displayname. - // Currently for debugging purposes we show the email - // before "@" as name because displaynames are all nil. - Text(contact.displayname ?? contact.email.components(separatedBy: "@")[0]) - Spacer() - Text(contact.lastHeardFrom) + Button { + if let index = model.selectedContacts.firstIndex(where: { $0.email == contact.email }) { + model.deselectContact(at: index) + } else { + model.selectContact(addr: contact) + } + } label: { + HStack { + // Profile picture + contact.avatar + .resizable() + .frame(width: 30, height: 30) + .aspectRatio(contentMode: .fit) + .clipShape(/*@START_MENU_TOKEN@*/Circle()/*@END_MENU_TOKEN@*/) + .shadow(radius: 2) + .padding(EdgeInsets(top: 0, leading: 3, bottom: 0, trailing: 8)) + + VStack(alignment: .leading) { + HStack { + // TODO: Show proper displayname. + // Currently for debugging purposes we show the email + // before "@" as name because displaynames are all nil. + Text(contact.displayname ?? contact.email.components(separatedBy: "@")[0]) + .foregroundColor(.primary) + Spacer() + Text(contact.lastHeardFrom) + .font(.caption) + .padding(.trailing, 16) + } + + // Currently only shows first email address of contact. + // TODO: Decide which email address(es) to show. + Text(contact.email) .font(.caption) - .foregroundColor(.secondary) - .padding(.trailing, 16) } + .foregroundColor(.secondary) + + Spacer() - // Currently only shows first email address of contact. - // TODO: Decide which email address(es) to show. - Text(contact.email) - .font(.caption) - .foregroundColor(.secondary) - } - - Spacer() - - // Adds or removes contact from recipients. - Button(action: { - if let index = model.selectedContacts.firstIndex(where: { $0.email == contact.email }) { - model.deselectContact(at: index) - } else { - model.selectContact(addr: contact) - } - }) { // TODO: Maybe use more robust identifier (Identifiable + UUID?) - Image(systemName: model.selectedContacts.contains(where: { $0.email == contact.email }) ? "checkmark.circle.fill" : "circle") + Image(systemName: model.selectedContacts.contains { + $0.email == contact.email + } ? "checkmark.circle.fill" : "circle") .foregroundColor(.accentColor) } } diff --git a/enzevalos_iphone/SwiftUI/Compose/RecipientsModel.swift b/enzevalos_iphone/SwiftUI/Compose/RecipientsModel.swift index c89fa559d72a29ac5563a15c81df1130cf6e5c34..223dd10a7dc53ca1a63d8c0adc71382dbddc802b 100644 --- a/enzevalos_iphone/SwiftUI/Compose/RecipientsModel.swift +++ b/enzevalos_iphone/SwiftUI/Compose/RecipientsModel.swift @@ -37,6 +37,7 @@ class RecipientsModel: ObservableObject { let ccModel: RecipientFieldModel let bccModel: RecipientFieldModel @Published var showBccField = false + var parentComposeModel: ComposeModel? /// Initializes models for "To", "Cc", and "Bcc" fields. init() { @@ -44,8 +45,9 @@ class RecipientsModel: ObservableObject { toModel = RecipientFieldModel(type: .to, dataprovider: dataprovider) ccModel = RecipientFieldModel(type: .ccBcc, dataprovider: dataprovider) bccModel = RecipientFieldModel(type: .bcc, dataprovider: dataprovider) - ccModel.parent = self - bccModel.parent = self + toModel.parentRecipientModel = self + ccModel.parentRecipientModel = self + bccModel.parentRecipientModel = self } /// Used to show or hide Bcc field @@ -59,9 +61,9 @@ class RecipientsModel: ObservableObject { /// Used to deactivate Send button if email has no recipients. var hasNoRecipients: Bool { - toModel.selectedContacts.isEmpty && - ccModel.selectedContacts.isEmpty && - bccModel.selectedContacts.isEmpty + toModel.selectedContacts.isEmpty + && ccModel.selectedContacts.isEmpty + && bccModel.selectedContacts.isEmpty } /// String array of email addresses in "To" field. diff --git a/enzevalos_iphone/SwiftUI/LetterboxModel.swift b/enzevalos_iphone/SwiftUI/LetterboxModel.swift index b8322b6662d502b9fc0d37fb67979a77a9d5cc94..c73d113fd03a29e61d7411ab8938b4100004cb81 100644 --- a/enzevalos_iphone/SwiftUI/LetterboxModel.swift +++ b/enzevalos_iphone/SwiftUI/LetterboxModel.swift @@ -45,13 +45,13 @@ enum UIState { class LetterboxModel: ObservableObject { static var instance: LetterboxModel { get { - if let instance = currentIntstance { + if let instance = currentInstance { return instance } return LetterboxModel() } } - private static var currentIntstance: LetterboxModel? + private static var currentInstance: LetterboxModel? let mailHandler = MailHandler() let dataProvider = PersistentDataProvider.dataProvider @@ -82,20 +82,16 @@ class LetterboxModel: ObservableObject { if flags.contains(.reachable) == false { // The target host is not reachable. return .notReachable - } - else if flags.contains(.isWWAN) == true { + } else if flags.contains(.isWWAN) == true { // WWAN connections are OK if the calling application is using the CFNetwork APIs. return .reachableViaWWAN - } - else if flags.contains(.connectionRequired) == false { + } else if flags.contains(.connectionRequired) == false { // If the target host is reachable and no connection is required then we'll assume that you're on Wi-Fi... return .reachableViaWiFi - } - else if (flags.contains(.connectionOnDemand) == true || flags.contains(.connectionOnTraffic) == true) && flags.contains(.interventionRequired) == false { + } else if (flags.contains(.connectionOnDemand) == true || flags.contains(.connectionOnTraffic) == true) && flags.contains(.interventionRequired) == false { // The connection is on-demand (or on-traffic) if the calling application is using the CFSocketStream or higher APIs and no [user] intervention is needed return .reachableViaWiFi - } - else { + } else { return .notReachable } } @@ -132,7 +128,7 @@ class LetterboxModel: ObservableObject { UserDefaults.standard.register(defaults: ["Signature.Switch": false]) UserDefaults.standard.register(defaults: ["Signature.Text": "Verfasst mit Letterbox. Mehr Informationen: http://letterbox-app.org"]) } - LetterboxModel.currentIntstance = self + LetterboxModel.currentInstance = self }