From 22d4809a24c5b5bc49efada7a43bf0e36e10379b Mon Sep 17 00:00:00 2001
From: Chris Offner <chrisoffner@pm.me>
Date: Mon, 29 Mar 2021 17:24:39 +0200
Subject: [PATCH] Implemented Pull to Refresh.

---
 enzevalos_iphone.xcodeproj/project.pbxproj    | 12 +--
 enzevalos_iphone/SearchHelper.swift           | 56 +++++-------
 .../SwiftUI/Inbox/InboxRefreshModel.swift     | 66 ++++++++++++++
 .../SwiftUI/Inbox/InboxView.swift             | 54 ++---------
 .../SwiftUI/Inbox/MailListView.swift          | 90 +++++++++----------
 .../SwiftUI/Inbox/RefreshModel.swift          | 29 ------
 .../SwiftUI/Inbox/UpdateMailsModel.swift      |  7 +-
 7 files changed, 145 insertions(+), 169 deletions(-)
 create mode 100644 enzevalos_iphone/SwiftUI/Inbox/InboxRefreshModel.swift
 delete mode 100644 enzevalos_iphone/SwiftUI/Inbox/RefreshModel.swift

diff --git a/enzevalos_iphone.xcodeproj/project.pbxproj b/enzevalos_iphone.xcodeproj/project.pbxproj
index cd445386..568990ed 100644
--- a/enzevalos_iphone.xcodeproj/project.pbxproj
+++ b/enzevalos_iphone.xcodeproj/project.pbxproj
@@ -27,8 +27,7 @@
 		3EB4FAA420120096001D0625 /* DialogOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EB4FAA320120096001D0625 /* DialogOption.swift */; };
 		3EC35F2420037651008BDF95 /* InvitationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EC35F2320037651008BDF95 /* InvitationHelper.swift */; };
 		3EC35F302003838E008BDF95 /* InvitationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EC35F2F2003838E008BDF95 /* InvitationTests.swift */; };
-		3F071ECF2611B03A00E121F0 /* RefreshModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F071ECE2611B03A00E121F0 /* RefreshModel.swift */; };
-		3F071ED32611BF6900E121F0 /* UpdateMailsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F071ED22611BF6900E121F0 /* UpdateMailsModel.swift */; };
+		3F071ED32611BF6900E121F0 /* InboxRefreshModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F071ED22611BF6900E121F0 /* InboxRefreshModel.swift */; };
 		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 */; };
@@ -304,8 +303,7 @@
 		3EB4FAA320120096001D0625 /* DialogOption.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DialogOption.swift; sourceTree = "<group>"; };
 		3EC35F2320037651008BDF95 /* InvitationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = InvitationHelper.swift; path = Invitation/InvitationHelper.swift; sourceTree = "<group>"; };
 		3EC35F2F2003838E008BDF95 /* InvitationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitationTests.swift; sourceTree = "<group>"; };
-		3F071ECE2611B03A00E121F0 /* RefreshModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshModel.swift; sourceTree = "<group>"; };
-		3F071ED22611BF6900E121F0 /* UpdateMailsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateMailsModel.swift; sourceTree = "<group>"; };
+		3F071ED22611BF6900E121F0 /* InboxRefreshModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxRefreshModel.swift; sourceTree = "<group>"; };
 		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>"; };
@@ -971,8 +969,7 @@
 				4750BDE02539C5FC00F6D5AB /* InboxView.swift */,
 				47FA8EAB254D77DE006883D0 /* MailListView.swift */,
 				47C3490125489F52008D290C /* MailRowView.swift */,
-				3F071ECE2611B03A00E121F0 /* RefreshModel.swift */,
-				3F071ED22611BF6900E121F0 /* UpdateMailsModel.swift */,
+				3F071ED22611BF6900E121F0 /* InboxRefreshModel.swift */,
 			);
 			path = Inbox;
 			sourceTree = "<group>";
@@ -1894,7 +1891,7 @@
 				4775D7AC243F18BC0052F2CC /* SecurityBriefingView.swift in Sources */,
 				971D404A2428C87E002FCD31 /* BadgeCaseView.swift in Sources */,
 				47C8225B24379EAE005BCE73 /* MessageViewMain.swift in Sources */,
-				3F071ED32611BF6900E121F0 /* UpdateMailsModel.swift in Sources */,
+				3F071ED32611BF6900E121F0 /* InboxRefreshModel.swift in Sources */,
 				47FAE30E2524AA97005A1BCB /* DataModel.xcdatamodeld in Sources */,
 				47A5D6E22294BF3B0084F81D /* TempKey.swift in Sources */,
 				47C8226B2438A86B005BCE73 /* SenderViewMain.swift in Sources */,
@@ -1970,7 +1967,6 @@
 				47BCAF5A259DE6770008FE4B /* SecretKeyListView.swift in Sources */,
 				A15D215B223BE5F4003E0CE0 /* TempAttachment.swift in Sources */,
 				4706D65F225B7B6B00B3F1D3 /* ItunesHandler.swift in Sources */,
-				3F071ECF2611B03A00E121F0 /* RefreshModel.swift in Sources */,
 				47EABF0A241A9C8700774A93 /* AuthenticationViewModel.swift in Sources */,
 				47FA8EAC254D77DE006883D0 /* MailListView.swift in Sources */,
 				47C8225924379EAE005BCE73 /* AttPreview.swift in Sources */,
diff --git a/enzevalos_iphone/SearchHelper.swift b/enzevalos_iphone/SearchHelper.swift
index b6bffdc3..bce506aa 100644
--- a/enzevalos_iphone/SearchHelper.swift
+++ b/enzevalos_iphone/SearchHelper.swift
@@ -8,51 +8,39 @@
 
 import Foundation
 
-/**
- A collection of helper methods that are used for the different search bars
- */
-
-/**
- Function to be used to find mails that contain the search terms. All terms (separated by spaces) need to be contained in the search text.
- - parameters:
-    - content: The String that will be searched
-    - searchText: Search terms (space-separated) that will be searched for
-*/
-func containsSearchTerms ( content : String?, searchText: String) -> Bool
-{
-    guard searchText.count > 0 else {
-        ///Case empty search
-        return true
+/// Function used to find mails that contain the search terms. All terms (separated by spaces) need to be contained in the search text.
+/// - Parameters:
+///   - content: The String that will be searched
+///   - searchText: Search terms (space-separated) that will be searched for
+func containsSearchTerms(content: String?, searchTerm: String) -> Bool {
+    guard searchTerm.count > 0 else {
+        return true             // Case empty search
     }
+    
     guard let content = content else {
-        //Case Mail has no body/subject
-        return false
+        return false            // Case Mail has no body/subject
     }
         
-    var longterms : [String] = []
-    var terms : [String] = []
-    //Break String into substrings separated by quoatation marks
-    longterms = searchText.components(separatedBy: "\"")
+    var longterms: [String] = []
+    var terms: [String] = []
+    
+    // Break string into substrings separated by quotation marks
+    longterms = searchTerm.components(separatedBy: "\"")
+    
+    // Even elements will be outside the quotation marks and need to be separated again
     var i = 0
-    //even elements will be outside the quotation marks and need to be separated again
-    while (i < longterms.count)
-    {
-        if i % 2 == 0
-        {
+    while (i < longterms.count) {
+        if i % 2 == 0 {
             terms.append(contentsOf: longterms[i].lowercased().components(separatedBy: " "))
-        }
-        else
-        {
+        } else {
             terms.append(longterms[i].lowercased())
         }
-        i+=1
+        i += 1
     }
     
     var found = true
-    for t in terms
-    {
-        if !(t == "")
-        {
+    for t in terms {
+        if !t.isEmpty {
             found = found && content.lowercased().contains(t)
         }
     }
diff --git a/enzevalos_iphone/SwiftUI/Inbox/InboxRefreshModel.swift b/enzevalos_iphone/SwiftUI/Inbox/InboxRefreshModel.swift
new file mode 100644
index 00000000..0fe04858
--- /dev/null
+++ b/enzevalos_iphone/SwiftUI/Inbox/InboxRefreshModel.swift
@@ -0,0 +1,66 @@
+//
+//  InboxRefreshModel.swift
+//  enzevalos_iphone
+//
+//  Created by Chris Offner on 29.03.21.
+//  Copyright © 2021 fu-berlin. All rights reserved.
+//
+
+import Foundation
+
+/// Model for Pull to Refresh and updating inbox.
+class InboxRefreshModel: ObservableObject {
+    @Published var updating = false             // updating inbox
+    @Published var refreshStarted = false       // pull to refresh sequence started
+    @Published var pullReleased = false
+    var folderPath: String
+    var pullOffsetBaseline: CGFloat = 0
+    var pullOffset: CGFloat = 0
+    
+    init(folderPath: String) {
+        self.folderPath = folderPath
+    }
+    
+    /// Text representation of inbox update state.
+    var lastUpdate: String {
+        var text = NSLocalizedString("Updating", comment: "updating...")
+        
+        if !updating {
+            let last = Date()
+            let dateFormatter = DateFormatter()
+            dateFormatter.locale = Locale.current
+            dateFormatter.timeStyle = .medium
+            let dateString = dateFormatter.string(from: last)
+            text = NSLocalizedString("LastUpdate", comment: "") + " " + dateString
+        }
+        
+        return text
+    }
+    
+    /// Retrieves new emails and updates the respective email folder.
+    func updateMails() {
+        guard !updating else {
+            return
+        }
+        
+        LetterboxModel
+            .instance
+            .mailHandler
+            .updateFolder(folderpath: folderPath) { error in
+                if error == nil {
+                    self.updating = false
+                }
+                // TODO: Add error message
+            }
+        updating = true
+    }
+    
+    /// Refreshes inbox and resets pull to refresh states.
+    func refresh() {
+        DispatchQueue.main.async {
+            self.updateMails()
+            self.pullReleased = false
+            self.refreshStarted = false
+        }
+    }
+}
diff --git a/enzevalos_iphone/SwiftUI/Inbox/InboxView.swift b/enzevalos_iphone/SwiftUI/Inbox/InboxView.swift
index 2ba3a58f..056ad5ac 100644
--- a/enzevalos_iphone/SwiftUI/Inbox/InboxView.swift
+++ b/enzevalos_iphone/SwiftUI/Inbox/InboxView.swift
@@ -8,9 +8,7 @@
 //
 
 import SwiftUI
-import CoreData
-// TODO:    Refactor to Model!
-//          Updating text -> Last update, updating, no connection...
+//import CoreData
 
 struct InboxView: View {    
     var folderPath: String
@@ -18,23 +16,23 @@ struct InboxView: View {
     @State private var updating = false
     @State private var composeMail = false
     @State private var goToFolders = false
-    @ObservedObject var mailUpdater: UpdateMailsModel
+    @ObservedObject var refreshModel: InboxRefreshModel
     
     init(folderPath: String, name: String) {
         self.folderPath = folderPath
         self.name = name
-        self.mailUpdater = UpdateMailsModel(folderPath: folderPath)
+        self.refreshModel = InboxRefreshModel(folderPath: folderPath)
     }
     
     var body: some View {
         mailListView
-            .onAppear(perform: mailUpdater.updateMails)
+            .onAppear(perform: refreshModel.updateMails)
             .sheet(isPresented: $composeMail) { ComposeView() }
             .navigationBarItems(trailing: keyManagementButton)
             .toolbar {
                 ToolbarItem(placement: .status) {
-                    Button(action: mailUpdater.updateMails) {
-                        Text(mailUpdater.lastUpdate).font(.callout)
+                    Button(action: refreshModel.updateMails) {
+                        Text(refreshModel.lastUpdate)
                     }
                 }
                 
@@ -46,7 +44,7 @@ struct InboxView: View {
     }
     
     private var mailListView: some View {
-        MailListView(folderPath: folderPath, folderName: name, mailUpdater: mailUpdater)
+        MailListView(folderPath: folderPath, folderName: name, refreshModel: refreshModel)
             .environment(\.managedObjectContext,
                          PersistentDataProvider
                             .dataProvider
@@ -70,44 +68,6 @@ struct InboxView: View {
         }
     }
     
-    //    // TODO: Refactor lastUpdate to model
-    //    private var lastUpdate: some View {
-    //        var text = NSLocalizedString("Updating", comment: "updating...")
-    //
-    //        if !updating {
-    //            let last = Date()
-    //            let dateFormatter = DateFormatter()
-    //            dateFormatter.locale = Locale.current
-    //            dateFormatter.timeStyle = .medium
-    //            let dateString = dateFormatter.string(from: last)
-    //            text = NSLocalizedString("LastUpdate", comment: "") + " " + dateString
-    //        }
-    //
-    //        return Button(action: updateMails) {
-    //            Text(text).font(.callout)
-    //        }
-    //    }
-    //
-    //
-    //    // TODO: Refactor updateMails to model
-    //    private func updateMails() {
-    //        guard !updating else {
-    //            return
-    //        }
-    //
-    //        LetterboxModel
-    //            .instance
-    //            .mailHandler
-    //            .updateFolder(folderpath: folderPath) { error in
-    //                if error == nil {
-    //                    self.updating = false
-    //                }
-    //                // TODO: Add error message
-    //            }
-    //        updating = true
-    //    }
-    
-    
     private var folderButton: some View {
         Button {
             goToFolders = true
diff --git a/enzevalos_iphone/SwiftUI/Inbox/MailListView.swift b/enzevalos_iphone/SwiftUI/Inbox/MailListView.swift
index 8a4291a3..f116eb33 100644
--- a/enzevalos_iphone/SwiftUI/Inbox/MailListView.swift
+++ b/enzevalos_iphone/SwiftUI/Inbox/MailListView.swift
@@ -9,42 +9,38 @@
 
 import SwiftUI
 import CoreData
-// TODO:    Refactor to Model!
-//          Updating text -> Last update, updating, no connection....
 
+/// A view that lists emails in a particular folder together with a search function.
 struct MailListView: View {
     @Environment(\.managedObjectContext) var managedObjectContext
-    @ObservedObject var model = RefreshModel()
     var fetchRequest: FetchRequest<MailRecord>
     var mails: FetchedResults<MailRecord>{fetchRequest.wrappedValue}
     var folderPath: String
     var folderName: String
-    var mailUpdater: UpdateMailsModel
-    
-    @State private var showUser = false
+    @ObservedObject var model: InboxRefreshModel
     @State private var searchText = ""
     @State private var searchType = SearchType.All
     
-    init(folderPath: String, folderName: String, mailUpdater: UpdateMailsModel) {
+    init(folderPath: String, folderName: String, refreshModel: InboxRefreshModel) {
         fetchRequest = MailRecord.mailsInFolderFetchRequest(folderpath: folderPath)
         self.folderPath = folderPath
         self.folderName = folderName
-        self.mailUpdater = mailUpdater
+        self.model = refreshModel
     }
     
     var body: some View {
-        VStack(alignment: .leading) {
-            SearchView(searchText: $searchText, searchType: $searchType)
-                .padding()
+        VStack(alignment: .leading, spacing: 0) {
+            SearchView(searchText: $searchText, searchType: $searchType).padding()
             
             ScrollView {
-                measurements.frame(width: 0, height: 0)
+                refreshTrigger.frame(width: 0, height: 0)
                 
-                VStack {
+                ZStack(alignment: topCenter) {
+                    mailList.frame(height: 600)
                     pullToRefreshIndicator
-                    mailList
                 }
-                .offset(y: model.released ? 50 : -10)
+                .offset(y: model.updating ? 35 : -10)
+                .animation(.default)
             }
         }
         .navigationBarTitle(folderName, displayMode: .inline)
@@ -66,44 +62,46 @@ struct MailListView: View {
     
     @ViewBuilder
     private var pullToRefreshIndicator: some View {
-        if model.started && model.released {
-            ProgressView()
-                .offset(y: -32)
+        if model.updating {
+            ProgressView().offset(y: -32)
         } else {
             Image(systemName: "arrow.down.circle")
                 .foregroundColor(.secondary)
-                .font(.system(size: 28, weight: .bold))
-                .rotationEffect(Angle(degrees: model.started ? 180 : 0))
+                .font(.system(size: 28, weight: .regular))
+                .rotationEffect(Angle(degrees: model.refreshStarted ? 180 : 0))
                 .offset(y: -32)
                 .animation(.easeIn)
         }
     }
     
-    private var measurements: GeometryReader<AnyView> {
+    private var topCenter: Alignment {
+        Alignment(horizontal: .center, vertical: .top)
+    }
+    
+    /// Measures pulldown offset and triggers inbox refresh.
+    private var refreshTrigger: GeometryReader<AnyView> {
         GeometryReader { geo -> AnyView in
             DispatchQueue.main.async {
-                if model.startOffset == 0 {
-                    model.startOffset = geo.frame(in: .global).minY
+                // Set initial baseline for pulldown based on dynamic layout
+                if model.pullOffsetBaseline == 0 {
+                    model.pullOffsetBaseline = geo.frame(in: .global).minY
                 }
                 
-                model.offset = geo.frame(in: .global).minY
+                // Update offset dynamically as user pulls down
+                model.pullOffset = geo.frame(in: .global).minY
                 
-                if model.offset - model.startOffset > 80 && !model.started {
-                    model.started = true
+                // Indicate refresh start if pulldown threshold is reached
+                if model.pullOffset - model.pullOffsetBaseline > 80 && !model.refreshStarted {
+                    model.refreshStarted = true
                 }
                 
-                if model.offset == model.startOffset && model.started {
-                    
-                    if !model.released {
-                        withAnimation(.linear) {
-                            model.released = true
-                            model.refresh(perform: mailUpdater.updateMails)
-                        }
-                    } else {
-                        if model.disabled {
-                            model.disabled = false
-                            model.refresh(perform: mailUpdater.updateMails)
-                        }
+                // Trigger refresh once pulldown is fully released
+                if model.pullOffset == model.pullOffsetBaseline
+                    && model.refreshStarted
+                    && !model.pullReleased {
+                    withAnimation(.linear) {
+                        model.pullReleased = true
+                        model.refresh()
                     }
                 }
             }
@@ -112,6 +110,7 @@ struct MailListView: View {
         }
     }
     
+    /// Filters emails based on search criteria.
     func filterKeyRecord(keyRecord: MailRecord) -> Bool {
         if self.searchText.isEmpty
             || self.searchText == NSLocalizedString("Searchbar.Title", comment: "Search") {
@@ -120,19 +119,20 @@ struct MailListView: View {
         
         let query = self.searchText.lowercased()
         if (searchType == .All || searchType == .Sender)
-            && (containsSearchTerms(content: keyRecord.sender.displayname, searchText: query)
-                    || containsSearchTerms(content: keyRecord.sender.email, searchText: query)) {
+            && (containsSearchTerms(content: keyRecord.sender.displayname, searchTerm: query)
+                    || containsSearchTerms(content: keyRecord.sender.email, searchTerm: query)) {
             return true
         } else if (searchType == .All || searchType == .Sender)
-                    && keyRecord.addresses.filter({
-                                                    containsSearchTerms(content: $0.email,
-                                                                        searchText: query) }).count > 0 {
+                    && (keyRecord.addresses.filter {
+                            containsSearchTerms(content: $0.email,
+                                                searchTerm: query) }
+                            .count > 0) {
             return true
         } else if (searchType == .All || searchType == .Subject)
-                    && containsSearchTerms(content: keyRecord.subject, searchText: query) {
+                    && containsSearchTerms(content: keyRecord.subject, searchTerm: query) {
             return true
         } else if (searchType == .All || searchType == .Body)
-                    && containsSearchTerms(content: keyRecord.body, searchText: query) {
+                    && containsSearchTerms(content: keyRecord.body, searchTerm: query) {
             return true
         }
         return false
diff --git a/enzevalos_iphone/SwiftUI/Inbox/RefreshModel.swift b/enzevalos_iphone/SwiftUI/Inbox/RefreshModel.swift
deleted file mode 100644
index e58f5fdf..00000000
--- a/enzevalos_iphone/SwiftUI/Inbox/RefreshModel.swift
+++ /dev/null
@@ -1,29 +0,0 @@
-//
-//  RefreshModel.swift
-//  enzevalos_iphone
-//
-//  Created by Chris Offner on 29.03.21.
-//  Copyright © 2021 fu-berlin. All rights reserved.
-//
-
-import Foundation
-
-class RefreshModel: ObservableObject {
-    var startOffset: CGFloat = 0
-    var offset: CGFloat = 0
-    var started = false
-    var released = false
-    var disabled = false
-    
-    func refresh(perform action: @escaping () -> Void) {
-        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
-            if self.offset == self.startOffset {
-                action()
-                self.released = false
-                self.started = false
-            } else {
-                self.disabled = true
-            }
-        }
-    }
-}
diff --git a/enzevalos_iphone/SwiftUI/Inbox/UpdateMailsModel.swift b/enzevalos_iphone/SwiftUI/Inbox/UpdateMailsModel.swift
index b94a4cbc..7359c830 100644
--- a/enzevalos_iphone/SwiftUI/Inbox/UpdateMailsModel.swift
+++ b/enzevalos_iphone/SwiftUI/Inbox/UpdateMailsModel.swift
@@ -8,7 +8,7 @@
 
 import Foundation
 
-class UpdateMailsModel: ObservableObject {
+class InboxRefreshModel: ObservableObject {
     @Published var updating = false
     var folderPath: String
     
@@ -48,8 +48,3 @@ class UpdateMailsModel: ObservableObject {
         updating = true
     }
 }
-
-
-
-
-
-- 
GitLab