├── .bundle └── config ├── .circleci └── config.yml ├── .gitignore ├── FiveCalls ├── Bridging-Header.h ├── FiveCalls.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ ├── FiveCalls.xcscheme │ │ └── FiveCallsUITests.xcscheme ├── FiveCalls │ ├── AboutListItem.swift │ ├── AboutSheet.swift │ ├── AcknowListView.swift │ ├── Actions.swift │ ├── AnalyticsManager.swift │ ├── App.swift │ ├── AppDelegate.swift │ ├── AppState.swift │ ├── Appearance.swift │ ├── AppearanceProxies.swift │ ├── AreaOffice.swift │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── Icon-60@2x.png │ │ │ ├── Icon-60@3x.png │ │ │ ├── Icon-Small-40@2x.png │ │ │ ├── Icon-Small-40@3x.png │ │ │ ├── Icon-Small@2x.png │ │ │ ├── Icon-Small@3x.png │ │ │ ├── Icon-iPad.png │ │ │ ├── Icon-iPad@2x.png │ │ │ ├── Icon-iPad_pro@2x.png │ │ │ └── iTunesArtwork@2x.png │ │ ├── Contents.json │ │ ├── fivecalls-logotype.imageset │ │ │ ├── 5calls-logotype.png │ │ │ └── Contents.json │ │ ├── fivecalls_stars.imageset │ │ │ ├── Contents.json │ │ │ ├── stars-bar.png │ │ │ ├── stars-bar@2x.png │ │ │ └── stars-bar@3x.png │ │ ├── iTunesArtwork.imageset │ │ │ ├── Contents.json │ │ │ ├── iTunesArtwork.png │ │ │ └── iTunesArtwork@2x.png │ │ └── stars-blue.imageset │ │ │ ├── 5calls-stars-med.png │ │ │ └── Contents.json │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── BaseOperation.swift │ ├── CategorizedIssuesViewModel.swift │ ├── Category.swift │ ├── Colors.xcassets │ │ ├── Contents.json │ │ ├── fiveCallsDarkGreenText.colorset │ │ │ └── Contents.json │ │ ├── fivecallsDarkBlue.colorset │ │ │ └── Contents.json │ │ ├── fivecallsDarkBlueText.colorset │ │ │ └── Contents.json │ │ ├── fivecallsDarkGray.colorset │ │ │ └── Contents.json │ │ ├── fivecallsGreen.colorset │ │ │ └── Contents.json │ │ ├── fivecallsLightBG.colorset │ │ │ └── Contents.json │ │ ├── fivecallsLightBlue.colorset │ │ │ └── Contents.json │ │ ├── fivecallsLightGray.colorset │ │ │ └── Contents.json │ │ ├── fivecallsMediumDarkGray.colorset │ │ │ └── Contents.json │ │ ├── fivecallsRed.colorset │ │ │ └── Contents.json │ │ ├── fivecallsRedText.colorset │ │ │ └── Contents.json │ │ ├── launchScreenBG.colorset │ │ │ └── Contents.json │ │ ├── lightBlueBackground.colorset │ │ │ └── Contents.json │ │ ├── lightGrayBackground.colorset │ │ │ └── Contents.json │ │ ├── mediumGray.colorset │ │ │ └── Contents.json │ │ └── superLightGray.colorset │ │ │ └── Contents.json │ ├── Contact.swift │ ├── ContactCircle.swift │ ├── ContactImages.swift │ ├── ContactList.swift │ ├── ContactListItem.swift │ ├── ContactLog.swift │ ├── Dashboard.swift │ ├── EmailComposerView.swift │ ├── Extensions │ │ └── UNNotification+ScheduleReminders.swift │ ├── FetchContactsOperation.swift │ ├── FetchIssuesOperation.swift │ ├── FetchMessagesOperation.swift │ ├── FetchStatsOperation.swift │ ├── FetchUserStatsOperation.swift │ ├── FiveCalls.entitlements │ ├── ImpactViewModel.swift │ ├── InboxDetail.swift │ ├── InboxMessage.swift │ ├── InboxRouter.swift │ ├── InboxView.swift │ ├── InboxVote.swift │ ├── Info.plist │ ├── Issue.swift │ ├── IssueContactDetail.swift │ ├── IssueDetail.swift │ ├── IssueDone.swift │ ├── IssueListItem.swift │ ├── IssueRouter.swift │ ├── IssueSplitView.swift │ ├── IssuesManager.swift │ ├── IssuesViewModel.swift │ ├── JSONSerializable.swift │ ├── Localizable.strings │ ├── LocationCoordinator.swift │ ├── LocationHeader.swift │ ├── LocationSheet.swift │ ├── MainHeader.swift │ ├── Middleware.swift │ ├── MultipleDayPicker.swift │ ├── NewsletterSignup.swift │ ├── NotificationNames.swift │ ├── Numbered.swift │ ├── Outcome.swift │ ├── OutcomesView.swift │ ├── PopoverTip.swift │ ├── PreviewContacts.swift │ ├── PreviewIssues.swift │ ├── PreviewMessages.swift │ ├── PrimaryButton.swift │ ├── ProtocolMock.swift │ ├── RatingPromptCounter.swift │ ├── ReportOutcomeOperation.swift │ ├── ScheduleReminders.swift │ ├── ScriptReplacements.swift │ ├── StatsViewModel.swift │ ├── Store.swift │ ├── StreakCounter.swift │ ├── UserDefaultsKey.swift │ ├── UserLocation.swift │ ├── UserStats.swift │ ├── WebView.swift │ ├── Welcome.swift │ ├── YourImpact.swift │ └── functions.swift ├── FiveCallsTests │ ├── AppStateTests.swift │ ├── ContactLogsTests.swift │ ├── ContactParsingTest.swift │ ├── Info.plist │ ├── IssueParsingTest.swift │ ├── IssueTests.swift │ ├── LocationSheetTests.swift │ ├── ReportParsingTest.swift │ ├── ScriptCustomizationTests.swift │ ├── StoreTests.swift │ ├── StreakCounterTests.swift │ └── UserLocationTests.swift ├── FiveCallsUITests │ ├── FiveCallsUITests.swift │ ├── GET-issues.json │ ├── GET-v1-issues-UI.json │ ├── GET-v1-issues.json │ ├── GET-v1-report.json │ ├── GET-v1-reps-UI.json │ ├── GET-v1-reps.json │ ├── Info.plist │ └── POST-report.json └── NotificationsService │ ├── Info.plist │ ├── NotificationService.swift │ └── NotificationsService.entitlements ├── Gemfile ├── LICENSE ├── README.md ├── fastlane ├── Appfile ├── Fastfile ├── README.md ├── Snapfile ├── SnapshotHelper.swift ├── SnapshotHelper2-3.swift └── changelog.txt ├── screenshots.zip └── vendor └── rswift /.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_PATH: "vendor/bundle" 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## R.swift 10 | *.generated.swift 11 | 12 | ## Various settings 13 | *.pbxuser 14 | !default.pbxuser 15 | *.mode1v3 16 | !default.mode1v3 17 | *.mode2v3 18 | !default.mode2v3 19 | *.perspectivev3 20 | !default.perspectivev3 21 | xcuserdata/ 22 | 23 | ## Other 24 | *.moved-aside 25 | *.xcuserstate 26 | FiveCalls/GoogleService-Info.plist 27 | 28 | ## Obj-C/Swift specific 29 | *.hmap 30 | *.ipa 31 | *.dSYM.zip 32 | *.dSYM 33 | 34 | ## Playgrounds 35 | timeline.xctimeline 36 | playground.xcworkspace 37 | 38 | # Swift Package Manager 39 | # 40 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 41 | # Packages/ 42 | .build/ 43 | 44 | # CocoaPods 45 | # 46 | # We recommend against adding the Pods directory to your .gitignore. However 47 | # you should judge for yourself, the pros and cons are mentioned at: 48 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 49 | # 50 | # Pods/ 51 | 52 | # Carthage 53 | # 54 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 55 | # Carthage/Checkouts 56 | 57 | # Carthage/Build 58 | 59 | # fastlane 60 | # 61 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 62 | # screenshots whenever they are needed. 63 | # For more information about the recommended setup visit: 64 | # https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md 65 | 66 | fastlane/report.xml 67 | fastlane/Preview.html 68 | screenshots 69 | fastlane/test_output 70 | .env 71 | .ruby-version 72 | vendor/ 73 | 74 | .DS_Store 75 | Gemfile.lock 76 | fastlane/xcov_report/index.html 77 | fastlane/xcov_report/xccovreport-0.xccovreport 78 | fastlane/xcov_report/resources/application.css 79 | fastlane/xcov_report/resources/application.js 80 | fastlane/xcov_report/resources/bootstrap.min.css 81 | fastlane/xcov_report/resources/bootstrap.min.js 82 | fastlane/xcov_report/resources/file_cpp.png 83 | fastlane/xcov_report/resources/file_objc.png 84 | fastlane/xcov_report/resources/file_swift.png 85 | fastlane/xcov_report/resources/jquery.min.js 86 | fastlane/xcov_report/resources/main.css 87 | fastlane/xcov_report/resources/main.js 88 | fastlane/xcov_report/resources/opensans.css 89 | fastlane/xcov_report/resources/xcov_logo.png 90 | fastlane/xcov_report/xccovarchive-0.xccovarchive/Coverage 91 | fastlane/xcov_report/xccovarchive-0.xccovarchive/Index 92 | fastlane/xcov_report/xccovarchive-0.xccovarchive/Metadata.plist 93 | -------------------------------------------------------------------------------- /FiveCalls/Bridging-Header.h: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "fad1f62fbef31923b2dae18186dc82734d8c8114ded568f06d63ec47248c5ccc", 3 | "pins" : [ 4 | { 5 | "identity" : "acknowlist", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/vtourraine/AcknowList", 8 | "state" : { 9 | "revision" : "7cf70f24c97e86f1321f25ec24d128c1542eb7ef", 10 | "version" : "3.0.1" 11 | } 12 | }, 13 | { 14 | "identity" : "onesignal-ios-sdk", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/OneSignal/OneSignal-iOS-SDK", 17 | "state" : { 18 | "revision" : "8e4e32f69d4cc8b3543040473aeebdc08ebfa7bf", 19 | "version" : "3.11.1" 20 | } 21 | }, 22 | { 23 | "identity" : "plausibleswift", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/nickoneill/PlausibleSwift", 26 | "state" : { 27 | "revision" : "522268b34b7f9ec4ebbe23a0209e74f6fbdc203e", 28 | "version" : "0.3.0" 29 | } 30 | }, 31 | { 32 | "identity" : "r.swift", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/mac-cain13/R.swift/", 35 | "state" : { 36 | "revision" : "a76220f2c4b73bdda670f4a318c6ec983399ac6d", 37 | "version" : "7.4.0" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-argument-parser", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/apple/swift-argument-parser", 44 | "state" : { 45 | "revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531", 46 | "version" : "1.2.3" 47 | } 48 | }, 49 | { 50 | "identity" : "xcodeedit", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/tomlokhorst/XcodeEdit", 53 | "state" : { 54 | "revision" : "b6b67389a0f1a6fdd9c6457a8ab5b02eaab13c5c", 55 | "version" : "2.9.2" 56 | } 57 | } 58 | ], 59 | "version" : 3 60 | } 61 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls.xcodeproj/xcshareddata/xcschemes/FiveCallsUITests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 55 | 61 | 62 | 63 | 64 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/AboutListItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AboutListItem.swift 3 | // FiveCalls 4 | // 5 | // Created by Christopher Selin on 9/26/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import AcknowList 10 | import SwiftUI 11 | 12 | enum AboutListItemType { 13 | case action(() -> Void) 14 | case url(URL) 15 | case acknowledgements 16 | } 17 | 18 | struct AboutListItem: View { 19 | var title: String 20 | var type: AboutListItemType 21 | 22 | @ViewBuilder 23 | var body: some View { 24 | switch type { 25 | case let .action(action): 26 | Button(action: action) { 27 | HStack { 28 | Text(title) 29 | .foregroundColor(.primary) 30 | Spacer() 31 | Image(systemName: "chevron.right") 32 | .renderingMode(.template) 33 | .tint(.primary) 34 | .accessibilityHidden(true) 35 | } 36 | .contentShape(Rectangle()) 37 | } 38 | .buttonStyle(.plain) 39 | case let .url(url): 40 | ShareLink(item: url) { 41 | HStack { 42 | Text(title) 43 | .foregroundColor(.primary) 44 | Spacer() 45 | Image(systemName: "chevron.right") 46 | .renderingMode(.template) 47 | .tint(.primary) 48 | .accessibilityHidden(true) 49 | } 50 | .accessibilityAddTraits(.isButton) 51 | .contentShape(Rectangle()) 52 | } 53 | .buttonStyle(.plain) 54 | case .acknowledgements: 55 | HStack { 56 | ZStack { 57 | NavigationLink { 58 | AcknowListView() 59 | .navigationTitle(R.string.localizable.aboutAcknowledgementsTitle()) 60 | .navigationBarTitleDisplayMode(.inline) 61 | .toolbarBackground(.visible) 62 | .toolbarBackground(Color.fivecallsDarkBlue) 63 | .toolbarColorScheme(.dark, for: .navigationBar) 64 | } label: { 65 | EmptyView() 66 | } 67 | .opacity(0) 68 | HStack { 69 | Text(title) 70 | .foregroundColor(.primary) 71 | Spacer() 72 | } 73 | } 74 | Spacer() 75 | Image(systemName: "chevron.right") 76 | .renderingMode(.template) 77 | .tint(.primary) 78 | .accessibilityHidden(true) 79 | } 80 | .accessibilityAddTraits(.isButton) 81 | 82 | } 83 | } 84 | } 85 | 86 | #Preview { 87 | VStack { 88 | AboutListItem(title: "test item action", type: .action({ let _ = true })) 89 | AboutListItem(title: "test url", type: .url(URL(string: "https://google.com")!)) 90 | AboutListItem(title: "test acknowledgements", type: .acknowledgements) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/AcknowListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AcknowListView.swift 3 | // FiveCalls 4 | // 5 | // Created by Christopher Selin on 1/3/24. 6 | // Copyright © 2024 5calls. All rights reserved. 7 | // 8 | 9 | import AcknowList 10 | import SwiftUI 11 | 12 | struct AcknowListView: UIViewControllerRepresentable { 13 | func makeUIViewController(context: Context) -> AcknowListViewController { 14 | return AcknowListViewController() 15 | } 16 | 17 | func updateUIViewController(_ uiViewController: AcknowListViewController, context: Context) { } 18 | } 19 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Actions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Action.swift 3 | // FiveCalls 4 | // 5 | // Created by Christopher Selin on 9/22/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | import Foundation 9 | 10 | enum Action { 11 | case ShowWelcomeScreen 12 | case FetchStats(Int?) 13 | case SetGlobalCallCount(Int) 14 | case SetIssueCallCount(Int,Int) 15 | case SetIssueContactCompletion(Int,String) 16 | case SetDonateOn(Bool) 17 | case FetchIssues 18 | case SetIssues([Issue]) 19 | case FetchContacts(UserLocation) 20 | case SetContacts([Contact]) 21 | case SetDistrict(String) 22 | case SetSplitDistrict(Bool) 23 | case SetLocation(UserLocation) 24 | case FetchMessages 25 | case SetMessages([InboxMessage]) 26 | case SelectMessage(InboxMessage?) 27 | case SelectMessageIDWhenLoaded(Int) 28 | case ReportOutcome(Issue, ContactLog, Outcome) 29 | case SetFetchingContacts(Bool) 30 | case SetLoadingStatsError(Error) 31 | case SetLoadingIssuesError(Error) 32 | case SetLoadingContactsError(Error) 33 | case SetNavigateToInboxMessage(String) 34 | case GoBack 35 | case GoToRoot 36 | case GoToNext(Issue, [Contact]) 37 | } 38 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/AnalyticsManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnalyticsManager.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 5/29/19. 6 | // Copyright © 2019 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import PlausibleSwift 11 | 12 | class AnalyticsManager { 13 | static let shared = AnalyticsManager() 14 | private var plausible: PlausibleSwift? 15 | 16 | private init() { 17 | plausible = try? PlausibleSwift(domain: "5calls.org") 18 | } 19 | 20 | var callerID: String { 21 | if let cid = UserDefaults.standard.string(forKey: UserDefaultsKey.callerID.rawValue) { 22 | return cid 23 | } 24 | 25 | let cid = UUID() 26 | UserDefaults.standard.setValue(cid.uuidString, forKey: UserDefaultsKey.callerID.rawValue) 27 | return cid.uuidString 28 | } 29 | 30 | func trackPageview(path: String, properties: [String: String] = .init()) { 31 | #if !DEBUG 32 | let alwaysUseProperties: [String: String] = ["isIOSApp": "true"] 33 | try? plausible?.trackPageview(path: path, properties: properties.merging(alwaysUseProperties) { _, new in new }) 34 | #endif 35 | } 36 | 37 | func trackEvent(name: String, path: String, properties: [String: String] = .init()) { 38 | #if !DEBUG 39 | let alwaysUseProperties: [String: String] = ["isIOSApp": "true"] 40 | try? plausible?.trackEvent(event: name, path: path, properties: properties.merging(alwaysUseProperties) { _, new in new }) 41 | #endif 42 | } 43 | 44 | func trackError(error: Error) { 45 | // no remote error tracking right now 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/App.swift: -------------------------------------------------------------------------------- 1 | // 2 | // App.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 6/28/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | @main 12 | struct FiveCallsApp: App { 13 | @StateObject var store: Store = Store(state: AppState(), middlewares: [appMiddleware()]) 14 | @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 15 | 16 | @Environment(\.scenePhase) private var scenePhase 17 | 18 | @AppStorage(UserDefaultsKey.hasShownWelcomeScreen.rawValue) var hasShownWelcomeScreen = false 19 | 20 | var body: some Scene { 21 | WindowGroup { 22 | IssueSplitView() 23 | .environmentObject(store) 24 | .sheet(isPresented: $store.state.showWelcomeScreen) { 25 | Welcome().environmentObject(store) 26 | } 27 | .onAppear { 28 | appDelegate.app = self 29 | if !hasShownWelcomeScreen { 30 | store.dispatch(action: .ShowWelcomeScreen) 31 | } 32 | } 33 | .onChange(of: scenePhase) { newPhase in 34 | if newPhase == .active { 35 | if store.state.needsIssueRefresh { 36 | store.dispatch(action: .FetchIssues) 37 | } 38 | } 39 | } 40 | } 41 | } 42 | } 43 | 44 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Appearance.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Appearance.swift 3 | // FiveCalls 4 | // 5 | // Created by Ben Scheirman on 2/22/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | enum Appearance { 12 | static func swiftUISetup() { 13 | let pageControlAppearance = UIPageControl.appearance() 14 | pageControlAppearance.pageIndicatorTintColor = R.color.fivecallsLightBlue() 15 | pageControlAppearance.currentPageIndicatorTintColor = R.color.fivecallsDarkBlue() 16 | UINavigationBar.appearance().backIndicatorImage = UIImage(systemName: "chevron.backward.circle.fill") 17 | UINavigationBar.appearance().backIndicatorTransitionMaskImage = UIImage(systemName: "chevron.backward.circle.fill") 18 | UIDatePicker.appearance().minuteInterval = 10 19 | UIDatePicker.appearance().roundsToMinuteInterval = true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/AppearanceProxies.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppearanceProxies.swift 3 | // FiveCalls 4 | // 5 | // Created by Brandon Titus on 2/2/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UILabel { 13 | 14 | // A variable to be used as a UIAppearance proxy to combine a font descriptor with the system provied font traits 15 | var substituteFontDescriptor: UIFontDescriptor { 16 | get { return font.fontDescriptor } 17 | set { 18 | var symbolicTraits = font.fontDescriptor.symbolicTraits 19 | 20 | symbolicTraits.insert(newValue.symbolicTraits) 21 | 22 | if let descriptor = UIFontDescriptor(fontAttributes: newValue.fontAttributes).withSymbolicTraits(symbolicTraits) { 23 | self.font = UIFont(descriptor: descriptor, size: font.pointSize) 24 | } else { 25 | // Fixes issue with iOS 9 not getting the custom font but may lose traits this way 26 | self.font = UIFont(descriptor: newValue, size: font.pointSize) 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/AreaOffice.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AreaOffice.swift 3 | // FiveCalls 4 | // 5 | // Created by Ben Scheirman on 2/4/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct AreaOffice: Decodable, Identifiable { 12 | let city: String 13 | let phone: String 14 | 15 | var id: Int { 16 | return phone.hashValue 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "size" : "29x29", 15 | "idiom" : "iphone", 16 | "filename" : "Icon-Small@2x.png", 17 | "scale" : "2x" 18 | }, 19 | { 20 | "size" : "29x29", 21 | "idiom" : "iphone", 22 | "filename" : "Icon-Small@3x.png", 23 | "scale" : "3x" 24 | }, 25 | { 26 | "size" : "40x40", 27 | "idiom" : "iphone", 28 | "filename" : "Icon-Small-40@2x.png", 29 | "scale" : "2x" 30 | }, 31 | { 32 | "size" : "40x40", 33 | "idiom" : "iphone", 34 | "filename" : "Icon-Small-40@3x.png", 35 | "scale" : "3x" 36 | }, 37 | { 38 | "size" : "60x60", 39 | "idiom" : "iphone", 40 | "filename" : "Icon-60@2x.png", 41 | "scale" : "2x" 42 | }, 43 | { 44 | "size" : "60x60", 45 | "idiom" : "iphone", 46 | "filename" : "Icon-60@3x.png", 47 | "scale" : "3x" 48 | }, 49 | { 50 | "idiom" : "ipad", 51 | "size" : "20x20", 52 | "scale" : "1x" 53 | }, 54 | { 55 | "idiom" : "ipad", 56 | "size" : "20x20", 57 | "scale" : "2x" 58 | }, 59 | { 60 | "idiom" : "ipad", 61 | "size" : "29x29", 62 | "scale" : "1x" 63 | }, 64 | { 65 | "idiom" : "ipad", 66 | "size" : "29x29", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "idiom" : "ipad", 71 | "size" : "40x40", 72 | "scale" : "1x" 73 | }, 74 | { 75 | "idiom" : "ipad", 76 | "size" : "40x40", 77 | "scale" : "2x" 78 | }, 79 | { 80 | "size" : "76x76", 81 | "idiom" : "ipad", 82 | "filename" : "Icon-iPad.png", 83 | "scale" : "1x" 84 | }, 85 | { 86 | "size" : "76x76", 87 | "idiom" : "ipad", 88 | "filename" : "Icon-iPad@2x.png", 89 | "scale" : "2x" 90 | }, 91 | { 92 | "size" : "83.5x83.5", 93 | "idiom" : "ipad", 94 | "filename" : "Icon-iPad_pro@2x.png", 95 | "scale" : "2x" 96 | }, 97 | { 98 | "size" : "1024x1024", 99 | "idiom" : "ios-marketing", 100 | "filename" : "iTunesArtwork@2x.png", 101 | "scale" : "1x" 102 | } 103 | ], 104 | "info" : { 105 | "version" : 1, 106 | "author" : "xcode" 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5calls/ios/f73e9bbbbfbc4eb64ef06c022e9abcfc47322e27/FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5calls/ios/f73e9bbbbfbc4eb64ef06c022e9abcfc47322e27/FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5calls/ios/f73e9bbbbfbc4eb64ef06c022e9abcfc47322e27/FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@2x.png -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5calls/ios/f73e9bbbbfbc4eb64ef06c022e9abcfc47322e27/FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@3x.png -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5calls/ios/f73e9bbbbfbc4eb64ef06c022e9abcfc47322e27/FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5calls/ios/f73e9bbbbfbc4eb64ef06c022e9abcfc47322e27/FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/Icon-iPad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5calls/ios/f73e9bbbbfbc4eb64ef06c022e9abcfc47322e27/FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/Icon-iPad.png -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/Icon-iPad@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5calls/ios/f73e9bbbbfbc4eb64ef06c022e9abcfc47322e27/FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/Icon-iPad@2x.png -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/Icon-iPad_pro@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5calls/ios/f73e9bbbbfbc4eb64ef06c022e9abcfc47322e27/FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/Icon-iPad_pro@2x.png -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5calls/ios/f73e9bbbbfbc4eb64ef06c022e9abcfc47322e27/FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x.png -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/fivecalls-logotype.imageset/5calls-logotype.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5calls/ios/f73e9bbbbfbc4eb64ef06c022e9abcfc47322e27/FiveCalls/FiveCalls/Assets.xcassets/fivecalls-logotype.imageset/5calls-logotype.png -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/fivecalls-logotype.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "5calls-logotype.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/fivecalls_stars.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "stars-bar.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "stars-bar@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "stars-bar@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/fivecalls_stars.imageset/stars-bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5calls/ios/f73e9bbbbfbc4eb64ef06c022e9abcfc47322e27/FiveCalls/FiveCalls/Assets.xcassets/fivecalls_stars.imageset/stars-bar.png -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/fivecalls_stars.imageset/stars-bar@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5calls/ios/f73e9bbbbfbc4eb64ef06c022e9abcfc47322e27/FiveCalls/FiveCalls/Assets.xcassets/fivecalls_stars.imageset/stars-bar@2x.png -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/fivecalls_stars.imageset/stars-bar@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5calls/ios/f73e9bbbbfbc4eb64ef06c022e9abcfc47322e27/FiveCalls/FiveCalls/Assets.xcassets/fivecalls_stars.imageset/stars-bar@3x.png -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/iTunesArtwork.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "iTunesArtwork.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "iTunesArtwork@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "version" : 1, 20 | "author" : "xcode" 21 | } 22 | } -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/iTunesArtwork.imageset/iTunesArtwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5calls/ios/f73e9bbbbfbc4eb64ef06c022e9abcfc47322e27/FiveCalls/FiveCalls/Assets.xcassets/iTunesArtwork.imageset/iTunesArtwork.png -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/iTunesArtwork.imageset/iTunesArtwork@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5calls/ios/f73e9bbbbfbc4eb64ef06c022e9abcfc47322e27/FiveCalls/FiveCalls/Assets.xcassets/iTunesArtwork.imageset/iTunesArtwork@2x.png -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/stars-blue.imageset/5calls-stars-med.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5calls/ios/f73e9bbbbfbc4eb64ef06c022e9abcfc47322e27/FiveCalls/FiveCalls/Assets.xcassets/stars-blue.imageset/5calls-stars-med.png -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Assets.xcassets/stars-blue.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "5calls-stars-med.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/BaseOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseOperation.swift 3 | // FiveCalls 4 | // 5 | // Created by Ben Scheirman on 1/30/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class BaseOperation: Operation, @unchecked Sendable { 12 | var session: URLSession = URLSession.shared 13 | 14 | override init() { 15 | super.init() 16 | 17 | // ideally we could avoid the app code knowing about testing at all, 18 | // and that would be possible if we were only unit testing and can 19 | // inject a mock session into our operations, but for UI testing we 20 | // also want to mock data and you can't reach into the app module to 21 | // configure anything so we're stuck with configuring via 22 | // appEnvironment for loading test data which you have to handle in 23 | // your actual app module 24 | if isUITesting() { 25 | let config = URLSessionConfiguration.ephemeral 26 | config.protocolClasses = [ProtocolMock.self] 27 | self.session = URLSession(configuration: config) 28 | } 29 | } 30 | 31 | override var isAsynchronous: Bool { 32 | return true 33 | } 34 | 35 | private var _executing = false { 36 | willSet { 37 | willChangeValue(forKey: "isExecuting") 38 | } 39 | didSet { 40 | didChangeValue(forKey: "isExecuting") 41 | } 42 | } 43 | 44 | override var isExecuting: Bool { 45 | return _executing 46 | } 47 | 48 | private var _finished = false { 49 | willSet { 50 | willChangeValue(forKey: "isFinished") 51 | } 52 | 53 | didSet { 54 | didChangeValue(forKey: "isFinished") 55 | } 56 | } 57 | 58 | override var isFinished: Bool { 59 | return _finished 60 | } 61 | 62 | func buildRequest(forURL url: URL) -> URLRequest { 63 | var request = URLRequest(url: url) 64 | request.setValue(AnalyticsManager.shared.callerID, forHTTPHeaderField: "X-Caller-ID") 65 | return request 66 | } 67 | 68 | override func start() { 69 | _executing = true 70 | execute() 71 | } 72 | 73 | func execute() { 74 | fatalError("You must override this") 75 | } 76 | 77 | func finish() { 78 | _executing = false 79 | _finished = true 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/CategorizedIssuesViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CategorizedIssuesViewModel.swift 3 | // FiveCalls 4 | // 5 | // Created by Ben Scheirman on 1/29/19. 6 | // Copyright © 2019 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class CategorizedIssuesViewModel: Identifiable { 12 | let category: Category 13 | var issues: [Issue] 14 | 15 | var name: String { 16 | return category.name 17 | } 18 | 19 | var id: Int { 20 | return name.hashValue 21 | } 22 | 23 | init(category: Category, issues: [Issue]) { 24 | self.category = category 25 | self.issues = issues 26 | } 27 | } 28 | 29 | extension CategorizedIssuesViewModel : Hashable { 30 | static func == (lhs: CategorizedIssuesViewModel, rhs: CategorizedIssuesViewModel) -> Bool { 31 | return lhs.category == rhs.category 32 | } 33 | 34 | func hash(into hasher: inout Hasher) { 35 | hasher.combine(self.category) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Category.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Category.swift 3 | // FiveCalls 4 | // 5 | // Created by Indrajit on 10/13/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | /* 10 | "categories": [{ 11 | "name": "Woman's Rights" 12 | }], 13 | */ 14 | 15 | // Category to which an issue may belong. 16 | struct Category : Decodable { 17 | let name: String 18 | } 19 | 20 | extension Category : Hashable, Comparable { 21 | static func < (lhs: Category, rhs: Category) -> Bool { 22 | return lhs.name < rhs.name 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Colors.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Colors.xcassets/fiveCallsDarkGreenText.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "53", 9 | "green" : "57", 10 | "red" : "0" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "light" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.208", 27 | "green" : "0.224", 28 | "red" : "0.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | }, 33 | { 34 | "appearances" : [ 35 | { 36 | "appearance" : "luminosity", 37 | "value" : "dark" 38 | } 39 | ], 40 | "color" : { 41 | "color-space" : "srgb", 42 | "components" : { 43 | "alpha" : "1.000", 44 | "blue" : "0.208", 45 | "green" : "0.374", 46 | "red" : "0.000" 47 | } 48 | }, 49 | "idiom" : "universal" 50 | } 51 | ], 52 | "info" : { 53 | "author" : "xcode", 54 | "version" : 1 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Colors.xcassets/fivecallsDarkBlue.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.810", 9 | "green" : "0.470", 10 | "red" : "0.120" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "light" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.810", 27 | "green" : "0.470", 28 | "red" : "0.120" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | }, 33 | { 34 | "appearances" : [ 35 | { 36 | "appearance" : "luminosity", 37 | "value" : "dark" 38 | } 39 | ], 40 | "color" : { 41 | "color-space" : "srgb", 42 | "components" : { 43 | "alpha" : "1.000", 44 | "blue" : "0.810", 45 | "green" : "0.470", 46 | "red" : "0.120" 47 | } 48 | }, 49 | "idiom" : "universal" 50 | } 51 | ], 52 | "info" : { 53 | "author" : "xcode", 54 | "version" : 1 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Colors.xcassets/fivecallsDarkBlueText.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.820", 9 | "green" : "0.460", 10 | "red" : "0.090" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "light" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.820", 27 | "green" : "0.460", 28 | "red" : "0.090" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | }, 33 | { 34 | "appearances" : [ 35 | { 36 | "appearance" : "luminosity", 37 | "value" : "dark" 38 | } 39 | ], 40 | "color" : { 41 | "color-space" : "srgb", 42 | "components" : { 43 | "alpha" : "1.000", 44 | "blue" : "0xEC", 45 | "green" : "0x99", 46 | "red" : "0x46" 47 | } 48 | }, 49 | "idiom" : "universal" 50 | } 51 | ], 52 | "info" : { 53 | "author" : "xcode", 54 | "version" : 1 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Colors.xcassets/fivecallsDarkGray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "gray-gamma-22", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "white" : "0.400" 9 | } 10 | }, 11 | "idiom" : "universal" 12 | }, 13 | { 14 | "appearances" : [ 15 | { 16 | "appearance" : "luminosity", 17 | "value" : "light" 18 | } 19 | ], 20 | "color" : { 21 | "color-space" : "gray-gamma-22", 22 | "components" : { 23 | "alpha" : "1.000", 24 | "white" : "0.400" 25 | } 26 | }, 27 | "idiom" : "universal" 28 | }, 29 | { 30 | "appearances" : [ 31 | { 32 | "appearance" : "luminosity", 33 | "value" : "dark" 34 | } 35 | ], 36 | "color" : { 37 | "color-space" : "gray-gamma-22", 38 | "components" : { 39 | "alpha" : "1.000", 40 | "white" : "0.806" 41 | } 42 | }, 43 | "idiom" : "universal" 44 | } 45 | ], 46 | "info" : { 47 | "author" : "xcode", 48 | "version" : 1 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Colors.xcassets/fivecallsGreen.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.360", 9 | "green" : "0.620", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "light" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.360", 27 | "green" : "0.620", 28 | "red" : "0.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | }, 33 | { 34 | "appearances" : [ 35 | { 36 | "appearance" : "luminosity", 37 | "value" : "dark" 38 | } 39 | ], 40 | "color" : { 41 | "color-space" : "srgb", 42 | "components" : { 43 | "alpha" : "1.000", 44 | "blue" : "0.360", 45 | "green" : "0.620", 46 | "red" : "0.000" 47 | } 48 | }, 49 | "idiom" : "universal" 50 | } 51 | ], 52 | "info" : { 53 | "author" : "xcode", 54 | "version" : 1 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Colors.xcassets/fivecallsLightBG.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "gray-gamma-22", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "white" : "218" 9 | } 10 | }, 11 | "idiom" : "universal" 12 | }, 13 | { 14 | "appearances" : [ 15 | { 16 | "appearance" : "luminosity", 17 | "value" : "light" 18 | } 19 | ], 20 | "color" : { 21 | "color-space" : "gray-gamma-22", 22 | "components" : { 23 | "alpha" : "1.000", 24 | "white" : "218" 25 | } 26 | }, 27 | "idiom" : "universal" 28 | }, 29 | { 30 | "appearances" : [ 31 | { 32 | "appearance" : "luminosity", 33 | "value" : "dark" 34 | } 35 | ], 36 | "color" : { 37 | "color-space" : "gray-gamma-22", 38 | "components" : { 39 | "alpha" : "1.000", 40 | "white" : "0.150" 41 | } 42 | }, 43 | "idiom" : "universal" 44 | } 45 | ], 46 | "info" : { 47 | "author" : "xcode", 48 | "version" : 1 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Colors.xcassets/fivecallsLightBlue.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.920", 9 | "green" : "0.820", 10 | "red" : "0.680" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "light" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.920", 27 | "green" : "0.820", 28 | "red" : "0.680" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | }, 33 | { 34 | "appearances" : [ 35 | { 36 | "appearance" : "luminosity", 37 | "value" : "dark" 38 | } 39 | ], 40 | "color" : { 41 | "color-space" : "srgb", 42 | "components" : { 43 | "alpha" : "1.000", 44 | "blue" : "0.920", 45 | "green" : "0.820", 46 | "red" : "0.680" 47 | } 48 | }, 49 | "idiom" : "universal" 50 | } 51 | ], 52 | "info" : { 53 | "author" : "xcode", 54 | "version" : 1 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Colors.xcassets/fivecallsLightGray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "gray-gamma-22", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "white" : "0.900" 9 | } 10 | }, 11 | "idiom" : "universal" 12 | }, 13 | { 14 | "appearances" : [ 15 | { 16 | "appearance" : "luminosity", 17 | "value" : "light" 18 | } 19 | ], 20 | "color" : { 21 | "color-space" : "gray-gamma-22", 22 | "components" : { 23 | "alpha" : "1.000", 24 | "white" : "0.900" 25 | } 26 | }, 27 | "idiom" : "universal" 28 | }, 29 | { 30 | "appearances" : [ 31 | { 32 | "appearance" : "luminosity", 33 | "value" : "dark" 34 | } 35 | ], 36 | "color" : { 37 | "color-space" : "gray-gamma-22", 38 | "components" : { 39 | "alpha" : "1.000", 40 | "white" : "0.900" 41 | } 42 | }, 43 | "idiom" : "universal" 44 | } 45 | ], 46 | "info" : { 47 | "author" : "xcode", 48 | "version" : 1 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Colors.xcassets/fivecallsMediumDarkGray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "gray-gamma-22", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "white" : "0.311" 9 | } 10 | }, 11 | "idiom" : "universal" 12 | }, 13 | { 14 | "appearances" : [ 15 | { 16 | "appearance" : "luminosity", 17 | "value" : "dark" 18 | } 19 | ], 20 | "color" : { 21 | "color-space" : "gray-gamma-22", 22 | "components" : { 23 | "alpha" : "1.000", 24 | "white" : "0.843" 25 | } 26 | }, 27 | "idiom" : "universal" 28 | } 29 | ], 30 | "info" : { 31 | "author" : "xcode", 32 | "version" : 1 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Colors.xcassets/fivecallsRed.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x25", 9 | "green" : "0x27", 10 | "red" : "0xA1" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "light" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x25", 27 | "green" : "0x27", 28 | "red" : "0xA1" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | }, 33 | { 34 | "appearances" : [ 35 | { 36 | "appearance" : "luminosity", 37 | "value" : "dark" 38 | } 39 | ], 40 | "color" : { 41 | "color-space" : "srgb", 42 | "components" : { 43 | "alpha" : "1.000", 44 | "blue" : "0x25", 45 | "green" : "0x27", 46 | "red" : "0xA1" 47 | } 48 | }, 49 | "idiom" : "universal" 50 | } 51 | ], 52 | "info" : { 53 | "author" : "xcode", 54 | "version" : 1 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Colors.xcassets/fivecallsRedText.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x25", 9 | "green" : "0x27", 10 | "red" : "0xA1" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x2A", 27 | "green" : "0x2A", 28 | "red" : "0xFF" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | }, 33 | { 34 | "appearances" : [ 35 | { 36 | "appearance" : "luminosity", 37 | "value" : "light" 38 | } 39 | ], 40 | "color" : { 41 | "color-space" : "srgb", 42 | "components" : { 43 | "alpha" : "1.000", 44 | "blue" : "0x25", 45 | "green" : "0x27", 46 | "red" : "0xA1" 47 | } 48 | }, 49 | "idiom" : "universal" 50 | } 51 | ], 52 | "info" : { 53 | "author" : "xcode", 54 | "version" : 1 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Colors.xcassets/launchScreenBG.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "gray-gamma-22", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "white" : "255" 9 | } 10 | }, 11 | "idiom" : "universal" 12 | }, 13 | { 14 | "appearances" : [ 15 | { 16 | "appearance" : "luminosity", 17 | "value" : "dark" 18 | } 19 | ], 20 | "color" : { 21 | "color-space" : "gray-gamma-22", 22 | "components" : { 23 | "alpha" : "1.000", 24 | "white" : "0" 25 | } 26 | }, 27 | "idiom" : "universal" 28 | } 29 | ], 30 | "info" : { 31 | "author" : "xcode", 32 | "version" : 1 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Colors.xcassets/lightBlueBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.820", 9 | "green" : "0.460", 10 | "red" : "0.090" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "light" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.820", 27 | "green" : "0.460", 28 | "red" : "0.090" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | }, 33 | { 34 | "appearances" : [ 35 | { 36 | "appearance" : "luminosity", 37 | "value" : "dark" 38 | } 39 | ], 40 | "color" : { 41 | "color-space" : "srgb", 42 | "components" : { 43 | "alpha" : "1.000", 44 | "blue" : "0.820", 45 | "green" : "0.460", 46 | "red" : "0.090" 47 | } 48 | }, 49 | "idiom" : "universal" 50 | } 51 | ], 52 | "info" : { 53 | "author" : "xcode", 54 | "version" : 1 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Colors.xcassets/lightGrayBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "gray-gamma-22", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "white" : "245" 9 | } 10 | }, 11 | "idiom" : "universal" 12 | }, 13 | { 14 | "appearances" : [ 15 | { 16 | "appearance" : "luminosity", 17 | "value" : "light" 18 | } 19 | ], 20 | "color" : { 21 | "color-space" : "gray-gamma-22", 22 | "components" : { 23 | "alpha" : "1.000", 24 | "white" : "245" 25 | } 26 | }, 27 | "idiom" : "universal" 28 | }, 29 | { 30 | "appearances" : [ 31 | { 32 | "appearance" : "luminosity", 33 | "value" : "dark" 34 | } 35 | ], 36 | "color" : { 37 | "color-space" : "gray-gamma-22", 38 | "components" : { 39 | "alpha" : "1.000", 40 | "white" : "0.961" 41 | } 42 | }, 43 | "idiom" : "universal" 44 | } 45 | ], 46 | "info" : { 47 | "author" : "xcode", 48 | "version" : 1 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Colors.xcassets/mediumGray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "gray-gamma-22", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "white" : "0.880" 9 | } 10 | }, 11 | "idiom" : "universal" 12 | }, 13 | { 14 | "appearances" : [ 15 | { 16 | "appearance" : "luminosity", 17 | "value" : "light" 18 | } 19 | ], 20 | "color" : { 21 | "color-space" : "gray-gamma-22", 22 | "components" : { 23 | "alpha" : "1.000", 24 | "white" : "0.880" 25 | } 26 | }, 27 | "idiom" : "universal" 28 | }, 29 | { 30 | "appearances" : [ 31 | { 32 | "appearance" : "luminosity", 33 | "value" : "dark" 34 | } 35 | ], 36 | "color" : { 37 | "color-space" : "gray-gamma-22", 38 | "components" : { 39 | "alpha" : "1.000", 40 | "white" : "0.880" 41 | } 42 | }, 43 | "idiom" : "universal" 44 | } 45 | ], 46 | "info" : { 47 | "author" : "xcode", 48 | "version" : 1 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Colors.xcassets/superLightGray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "gray-gamma-22", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "white" : "0.960" 9 | } 10 | }, 11 | "idiom" : "universal" 12 | }, 13 | { 14 | "appearances" : [ 15 | { 16 | "appearance" : "luminosity", 17 | "value" : "light" 18 | } 19 | ], 20 | "color" : { 21 | "color-space" : "gray-gamma-22", 22 | "components" : { 23 | "alpha" : "1.000", 24 | "white" : "245" 25 | } 26 | }, 27 | "idiom" : "universal" 28 | }, 29 | { 30 | "appearances" : [ 31 | { 32 | "appearance" : "luminosity", 33 | "value" : "dark" 34 | } 35 | ], 36 | "color" : { 37 | "color-space" : "gray-gamma-22", 38 | "components" : { 39 | "alpha" : "1.000", 40 | "white" : "0.960" 41 | } 42 | }, 43 | "idiom" : "universal" 44 | } 45 | ], 46 | "info" : { 47 | "author" : "xcode", 48 | "version" : 1 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/ContactCircle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContactCircle.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 8/3/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ContactCircle: View { 12 | @EnvironmentObject var store: Store 13 | 14 | let issueID: Int? 15 | let contact: Contact 16 | 17 | init(contact: Contact, issueID: Int? = nil) { 18 | self.contact = contact 19 | self.issueID = issueID 20 | } 21 | 22 | var body: some View { 23 | if let issueID, store.state.issueCalledOn(issueID: issueID, contactID: contact.id) { 24 | Image(systemName: "checkmark.circle.fill") 25 | .resizable() 26 | .foregroundColor(.fivecallsGreen) 27 | .background { 28 | Circle().foregroundColor(.white) 29 | } 30 | } else if contact.photoURL != nil { 31 | AsyncImage(url: contact.photoURL, content: { image in 32 | image.resizable() 33 | .aspectRatio(contentMode: .fill) 34 | .mask { 35 | Circle() 36 | } 37 | }) { 38 | placeholder 39 | } 40 | } else { 41 | placeholder 42 | } 43 | } 44 | 45 | var placeholder: some View { 46 | Image(uiImage: defaultImage(forContact: contact)) 47 | .resizable() 48 | .mask { 49 | Circle() 50 | } 51 | } 52 | } 53 | 54 | #Preview { 55 | let storeWithCompletedIssues: Store = { 56 | let state = AppState() 57 | state.issueCompletion[123] = ["1234-contact"] 58 | return Store(state: state) 59 | }() 60 | 61 | return HStack { 62 | ContactCircle(contact: Contact.housePreviewContact) 63 | .frame(width: 40, height: 40) 64 | ContactCircle(contact: Contact.housePreviewContact, issueID: 123) 65 | .frame(width: 40, height: 40) 66 | .environmentObject(storeWithCompletedIssues) 67 | ContactCircle(contact: Contact.senatePreviewContact1) 68 | .frame(width: 40) 69 | ContactCircle(contact: Contact.weirdShapeImagePreviewContact) 70 | .frame(width: 40, height: 40) 71 | Circle() 72 | .frame(width: 40, height: 40) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/ContactImages.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContactImages.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 8/3/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | private let defaultImageCache = NSCache() 12 | 13 | func defaultImage(forContact contact: Contact) -> UIImage { 14 | if let cachedImage = defaultImageCache.object(forKey: NSString(string: contact.id)) { 15 | return cachedImage 16 | } 17 | 18 | var finalImage: UIImage 19 | UIGraphicsBeginImageContext(CGSize(width: 256, height: 256)) 20 | 21 | let colorIndex = abs(Int(contact.id.hash)) % sampleColors.count 22 | 23 | var partyColor: UIColor { 24 | switch contact.party { 25 | case "Democrat": return hexStringToColor(hexString: "#4C72A9") 26 | case "Republican": return hexStringToColor(hexString: "#A94C47") 27 | case "Independent": return hexStringToColor(hexString: "#9063CD") 28 | default: return sampleColors[colorIndex] 29 | } 30 | } 31 | 32 | partyColor.setFill() 33 | 34 | let context = UIGraphicsGetCurrentContext() 35 | context?.fill([CGRect(origin: .zero, size: CGSize(width: 256, height: 256))]) 36 | 37 | finalImage = UIGraphicsGetImageFromCurrentImageContext()! 38 | UIGraphicsEndImageContext() 39 | 40 | defaultImageCache.setObject(finalImage, forKey: NSString(string: contact.id)) 41 | 42 | return finalImage 43 | } 44 | 45 | private let sampleColors: [UIColor] = [ 46 | "#CAD2C5", 47 | "#84A98C", 48 | "#52796F", 49 | "#354F52", 50 | "#2F3E46", 51 | "#25283D", 52 | "#8F3985", 53 | "#A675A1", 54 | "#CEA2AC", 55 | "#EFD9CE", 56 | ].map(hexStringToColor) 57 | 58 | func hexStringToColor(hexString: String) -> UIColor { 59 | let scanner = Scanner(string: String(hexString.dropFirst())) 60 | var hexNumber: UInt64 = 0 61 | scanner.scanHexInt64(&hexNumber) 62 | let r = CGFloat((hexNumber & 0xff0000) >> 16) / 255 63 | let g = CGFloat((hexNumber & 0x00ff00) >> 8) / 255 64 | let b = CGFloat(hexNumber & 0x0000ff) / 255 65 | return UIColor(red: r, green: g, blue: b, alpha: 1.0) 66 | } 67 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/ContactList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContactList.swift 3 | // FiveCalls 4 | // 5 | // Created by Ben Scheirman on 1/9/19. 6 | // Copyright © 2019 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class ContactList : Decodable { 12 | let location: String 13 | let isSplit: Bool 14 | let state: String 15 | let district: String 16 | let representatives: [Contact] 17 | 18 | var generalizedLocationID: String { 19 | return "\(state)-\(district)" 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/EmailComposerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmailComposerView.swift 3 | // FiveCalls 4 | // 5 | // Created by Christopher Selin on 9/26/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import MessageUI 10 | import SwiftUI 11 | 12 | struct EmailComposerView: UIViewControllerRepresentable { 13 | @Environment(\.presentationMode) private var presentationMode 14 | var result: (Result) -> Void 15 | 16 | static func canSendEmail() -> Bool { 17 | MFMailComposeViewController.canSendMail() 18 | } 19 | 20 | func makeUIViewController(context: Context) -> MFMailComposeViewController { 21 | let emailComposer = MFMailComposeViewController() 22 | emailComposer.mailComposeDelegate = context.coordinator 23 | emailComposer.setToRecipients(["hello@5calls.org"]) 24 | emailComposer.setMessageBody("", isHTML: true) 25 | return emailComposer 26 | } 27 | 28 | func updateUIViewController(_ uiViewController: MFMailComposeViewController, 29 | context: Context) { } 30 | 31 | func makeCoordinator() -> Coordinator { 32 | Coordinator(self) 33 | } 34 | 35 | class Coordinator: NSObject, MFMailComposeViewControllerDelegate { 36 | var parent: EmailComposerView 37 | 38 | init(_ parent: EmailComposerView) { 39 | self.parent = parent 40 | } 41 | 42 | func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) { 43 | 44 | if let error = error { 45 | parent.result(.failure(error)) 46 | return 47 | } 48 | 49 | parent.result(.success(result)) 50 | 51 | parent.presentationMode.wrappedValue.dismiss() 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Extensions/UNNotification+ScheduleReminders.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Notification+Extension.swift 3 | // FiveCalls 4 | // 5 | // Created by Christopher Selin on 9/15/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import UserNotifications 10 | import UIKit 11 | import RswiftResources 12 | 13 | extension UNMutableNotificationContent { 14 | static func notificationContent() -> UNMutableNotificationContent { 15 | let notificationContent = UNMutableNotificationContent() 16 | notificationContent.title = R.string.localizable.scheduledReminderAlertTitle() 17 | notificationContent.body = R.string.localizable.scheduledReminderAlertBody() 18 | notificationContent.badge = NSNumber(value: UIApplication.shared.applicationIconBadgeNumber + 1) 19 | return notificationContent 20 | } 21 | } 22 | 23 | extension UNCalendarNotificationTrigger { 24 | static func notificationTrigger(date: Date, dayIndex: Int, fromZeroBased: Bool = false) -> UNCalendarNotificationTrigger { 25 | var components = Calendar.current.dateComponents([.hour,.minute,.second], from: date) 26 | components.timeZone = TimeZone(identifier: "default") 27 | components.weekday = fromZeroBased ? dayIndex + 1 : dayIndex 28 | return UNCalendarNotificationTrigger(dateMatching: components, repeats: true) 29 | } 30 | } 31 | 32 | extension UNNotificationRequest { 33 | static func indices(from notifications: [UNNotificationRequest], zeroBased: Bool = false) -> [Int] { 34 | let calendar = Calendar(identifier: .gregorian) 35 | return notifications.compactMap({ notification in 36 | if let calendarTrigger = notification.trigger as? UNCalendarNotificationTrigger { 37 | return calendar.component(.weekday, from: (calendarTrigger.nextTriggerDate()!)) - (zeroBased ? 1 : 0) 38 | } 39 | 40 | return nil 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/FetchContactsOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchContactsOperation.swift 3 | // FiveCalls 4 | // 5 | // Created by Ben Scheirman on 1/9/19. 6 | // Copyright © 2019 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import OneSignal 11 | 12 | class FetchContactsOperation: BaseOperation, @unchecked Sendable { 13 | 14 | var location: UserLocation 15 | 16 | var httpResponse: HTTPURLResponse? 17 | var error: Error? 18 | var contacts: [Contact]? 19 | var splitDistrict: Bool? 20 | var district: String? 21 | 22 | init(location: UserLocation, config: URLSessionConfiguration? = nil) { 23 | self.location = location 24 | 25 | super.init() 26 | if let config { 27 | self.session = URLSession(configuration: config) 28 | } 29 | } 30 | 31 | var url: URL { 32 | var components = URLComponents(string: "https://api.5calls.org/v1/reps") 33 | let locationQueryParam = URLQueryItem(name: "location", value: location.locationValue) 34 | components?.queryItems = [locationQueryParam] 35 | return components!.url! 36 | } 37 | 38 | override func execute() { 39 | let request = buildRequest(forURL: url) 40 | 41 | let task = session.dataTask(with: request) { data, response, error in 42 | if let e = error { 43 | self.error = e 44 | } else { 45 | self.handleResponse(data: data, response: response) 46 | } 47 | self.finish() 48 | } 49 | task.resume() 50 | } 51 | 52 | private func handleResponse(data: Data?, response: URLResponse?) { 53 | guard let data = data else { return } 54 | guard let http = response as? HTTPURLResponse else { return } 55 | 56 | print("HTTP \(http.statusCode)") 57 | httpResponse = http 58 | 59 | if http.statusCode == 200 { 60 | do { 61 | self.contacts = try parseContacts(data: data) 62 | } catch let e { 63 | print("Error parsing reps: \(e.localizedDescription)") 64 | } 65 | } else { 66 | print("Received HTTP \(http.statusCode)") 67 | } 68 | } 69 | 70 | private func parseContacts(data: Data) throws -> [Contact] { 71 | let decoder = JSONDecoder() 72 | decoder.dateDecodingStrategy = .iso8601 73 | let contactList = try decoder.decode(ContactList.self, from: data) 74 | 75 | splitDistrict = contactList.isSplit 76 | if contactList.generalizedLocationID != "-" { 77 | district = contactList.generalizedLocationID 78 | OneSignal.sendTag("districtID", value: contactList.generalizedLocationID) 79 | } 80 | return contactList.representatives 81 | } 82 | } 83 | 84 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/FetchIssuesOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchIssuesOperation.swift 3 | // FiveCalls 4 | // 5 | // Created by Ben Scheirman on 1/31/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class FetchIssuesOperation: BaseOperation, @unchecked Sendable { 12 | 13 | // Output properties. 14 | // Once the job has finished consumers can check one or more of these for values. 15 | var httpResponse: HTTPURLResponse? 16 | var error: Error? 17 | var issuesList: [Issue]? 18 | 19 | init(config: URLSessionConfiguration? = nil) { 20 | super.init() 21 | 22 | if let config { 23 | self.session = URLSession(configuration: config) 24 | } 25 | } 26 | 27 | var url: URL { 28 | var urlComponents = URLComponents(string: "https://api.5calls.org/v1/issues")! 29 | var queryItems = [URLQueryItem(name: "includeHidden", value: "true")] 30 | 31 | // Add calling group if it exists 32 | if let callingGroup = UserDefaults.standard.string(forKey: UserDefaultsKey.callingGroup.rawValue), 33 | !callingGroup.isEmpty { 34 | queryItems.append(URLQueryItem(name: "group", value: callingGroup)) 35 | } 36 | 37 | urlComponents.queryItems = queryItems 38 | return urlComponents.url! 39 | } 40 | 41 | override func execute() { 42 | let request = buildRequest(forURL: url) 43 | 44 | let task = session.dataTask(with: request) { (data, response, error) in 45 | if let e = error { 46 | print("Error fetching issues: \(e.localizedDescription)") 47 | self.error = e 48 | } else { 49 | self.handleResponse(data: data, response: response) 50 | } 51 | 52 | self.finish() 53 | } 54 | 55 | task.resume() 56 | } 57 | 58 | private func handleResponse(data: Data?, response: URLResponse?) { 59 | guard let data = data else { 60 | print("data was nil, ignoring response") 61 | return 62 | } 63 | guard let http = response as? HTTPURLResponse else { 64 | print("Response was not an HTTP URL response (or was nil), ignoring") 65 | return 66 | } 67 | 68 | httpResponse = http 69 | 70 | if http.statusCode == 200 { 71 | do { 72 | self.issuesList = try parseIssues(data: data) 73 | } catch let e { 74 | print("Error parsing issues: \(e.localizedDescription)") 75 | } 76 | } else { 77 | print("Received HTTP \(http.statusCode)") 78 | } 79 | } 80 | 81 | private func parseIssues(data: Data) throws -> [Issue] { 82 | let decoder = JSONDecoder() 83 | decoder.dateDecodingStrategy = .iso8601 84 | return try decoder.decode([Issue].self, from: data) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/FetchMessagesOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchMessagesOperation.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 3/17/24. 6 | // Copyright © 2024 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class FetchMessagesOperation: BaseOperation, @unchecked Sendable { 12 | var district: String 13 | 14 | var error: Error? 15 | var httpResponse: HTTPURLResponse? 16 | var messages: [InboxMessage]? 17 | 18 | init(district: String, config: URLSessionConfiguration? = nil) { 19 | self.district = district 20 | 21 | super.init() 22 | if let config { 23 | self.session = URLSession(configuration: config) 24 | } 25 | } 26 | 27 | var url: URL { 28 | var components = URLComponents(string: "https://api.5calls.org/v1/users/inbox") 29 | let districtQueryParam = URLQueryItem(name: "district", value: district) 30 | components?.queryItems = [districtQueryParam] 31 | return components!.url! 32 | } 33 | 34 | override func execute() { 35 | let request = buildRequest(forURL: url) 36 | 37 | let task = session.dataTask(with: request) { data, response, error in 38 | if let e = error { 39 | self.error = e 40 | } else { 41 | self.handleResponse(data: data, response: response) 42 | } 43 | self.finish() 44 | } 45 | task.resume() 46 | } 47 | 48 | private func handleResponse(data: Data?, response: URLResponse?) { 49 | guard let data = data else { return } 50 | guard let http = response as? HTTPURLResponse else { return } 51 | 52 | httpResponse = http 53 | 54 | if http.statusCode == 200 { 55 | do { 56 | self.messages = try parseMessages(data: data) 57 | } catch let e { 58 | print("Error parsing messages: \(e.localizedDescription)") 59 | } 60 | } else { 61 | print("Received HTTP \(http.statusCode)") 62 | } 63 | } 64 | 65 | private func parseMessages(data: Data) throws -> [InboxMessage] { 66 | let decoder = JSONDecoder() 67 | decoder.dateDecodingStrategy = .iso8601 68 | let messagesResponse = try decoder.decode(MessagesRespsonse.self, from: data) 69 | 70 | return messagesResponse.messages 71 | } 72 | 73 | struct MessagesRespsonse: Decodable { 74 | var messages: [InboxMessage] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/FetchStatsOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchCallsOperation.swift 3 | // FiveCalls 4 | // 5 | // Created by Ben Scheirman on 1/30/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class FetchStatsOperation: BaseOperation, @unchecked Sendable { 12 | 13 | var numberOfCalls: Int? 14 | var numberOfIssueCalls: Int? 15 | var donateOn: Bool? 16 | var issueID: String? 17 | var httpResponse: HTTPURLResponse? 18 | var error: Error? 19 | 20 | init(config: URLSessionConfiguration? = nil) { 21 | super.init() 22 | 23 | if let config { 24 | self.session = URLSession(configuration: config) 25 | } 26 | } 27 | 28 | var url: URL { 29 | var urlComponents = URLComponents(url: URL(string: "https://api.5calls.org/v1/report")!, resolvingAgainstBaseURL: false)! 30 | var queryItems: [URLQueryItem] = [] 31 | 32 | if let issueID = self.issueID { 33 | queryItems.append(URLQueryItem(name: "issueID", value: issueID)) 34 | } 35 | 36 | // Add calling group if it exists 37 | if let callingGroup = UserDefaults.standard.string(forKey: UserDefaultsKey.callingGroup.rawValue), 38 | !callingGroup.isEmpty { 39 | queryItems.append(URLQueryItem(name: "group", value: callingGroup)) 40 | } 41 | 42 | if !queryItems.isEmpty { 43 | urlComponents.queryItems = queryItems 44 | } 45 | 46 | return urlComponents.url! 47 | } 48 | 49 | override func execute() { 50 | let request = buildRequest(forURL: url) 51 | 52 | let task = session.dataTask(with: request) { (data, response, error) in 53 | if let e = error { 54 | self.error = e 55 | } else { 56 | let http = response as! HTTPURLResponse 57 | self.httpResponse = http 58 | if let data = data, http.statusCode == 200 { 59 | do { 60 | try self.parseResponse(data: data) 61 | } catch let e as NSError { 62 | // log an continue, not worth crashing over 63 | print("Error parsing count: \(e.localizedDescription)") 64 | } 65 | } 66 | } 67 | 68 | DispatchQueue.main.async { 69 | self.finish() 70 | } 71 | } 72 | task.resume() 73 | } 74 | 75 | private func parseResponse(data: Data) throws { 76 | guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String:AnyObject] else { 77 | print("Couldn't parse JSON data.") 78 | return 79 | } 80 | 81 | if let count = json["count"] as? Int { 82 | self.numberOfCalls = count 83 | } else if let countString = json["count"] as? String { 84 | if let number = NumberFormatter().number(from: countString) { 85 | self.numberOfCalls = number.intValue 86 | } 87 | } 88 | 89 | if let issueCount = json["issueCount"] as? Int { 90 | self.numberOfIssueCalls = issueCount 91 | } 92 | 93 | if let donateOn = json["donateOn"] as? Bool { 94 | self.donateOn = donateOn 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/FetchUserStatsOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchUserStatsOperation.swift 3 | // FiveCalls 4 | // 5 | // Created by Mel Stanley on 1/31/18. 6 | // Copyright © 2018 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class FetchUserStatsOperation: BaseOperation, @unchecked Sendable { 12 | 13 | class TokenExpiredError : Error { } 14 | 15 | var userStats: UserStats? 16 | var firstCallTime: Date? 17 | var httpResponse: HTTPURLResponse? 18 | var error: Error? 19 | 20 | private var retryCount = 0 21 | 22 | var url: URL { 23 | return URL(string: "https://api.5calls.org/v1/users/stats")! 24 | } 25 | 26 | override func execute() { 27 | let config = URLSessionConfiguration.default 28 | let session = URLSession(configuration: config) 29 | 30 | let request = buildRequest(forURL: url) 31 | let task = session.dataTask(with: request) { (data, response, error) in 32 | 33 | if let e = error { 34 | self.error = e 35 | } else { 36 | let http = response as! HTTPURLResponse 37 | self.httpResponse = http 38 | guard let data = data else { return } 39 | 40 | switch http.statusCode { 41 | case 200: 42 | do { 43 | try self.parseResponse(data: data) 44 | } catch let e as NSError { 45 | // log an continue, not worth crashing over 46 | print("Error parsing user stats: \(e.localizedDescription)") 47 | } 48 | self.finish() 49 | 50 | default: 51 | print("Received HTTP \(http.statusCode) while fetching stats") 52 | self.finish() 53 | } 54 | } 55 | } 56 | task.resume() 57 | } 58 | 59 | private func parseResponse(data: Data) throws { 60 | // We expect the response to look like this: 61 | // { stats: { 62 | // "contact": 221, 63 | // "voicemail": 158, 64 | // "unavailable": 32 65 | // }, 66 | // weeklyStreak: 10, 67 | // firstCallTime: 1487959763 68 | // } 69 | guard let statsDictionary = try JSONSerialization.jsonObject(with: data, options: []) as? [String:AnyObject] else { 70 | print("Couldn't parse JSON data.") 71 | return 72 | } 73 | 74 | userStats = UserStats(dictionary: statsDictionary as JSONDictionary) 75 | 76 | if let firstCallUnixSeconds = statsDictionary["firstCallTime"] as? Double { 77 | firstCallTime = Date(timeIntervalSince1970: firstCallUnixSeconds) 78 | } 79 | } 80 | } 81 | 82 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/FiveCalls.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.associated-domains 8 | 9 | applinks:5calls.org 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/ImpactViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImpactViewModel.swift 3 | // FiveCalls 4 | // 5 | // Created by Ben Scheirman on 2/6/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct ImpactViewModel { 12 | 13 | let userStats: UserStats? 14 | let logs: ContactLogs 15 | 16 | init(logs: ContactLogs, stats: UserStats?) { 17 | self.logs = logs 18 | self.userStats = stats 19 | } 20 | 21 | var numberOfCalls: Int { 22 | return (userStats?.totalCalls() ?? 0) + logs.unreported().count 23 | } 24 | 25 | var madeContactCount: Int { 26 | return (userStats?.contact ?? 0) + logs.unreported().filter { $0.outcome == "contact" || $0.outcome == "contacted" }.count 27 | } 28 | 29 | var unavailableCount: Int { 30 | return (userStats?.unavailable ?? 0) + logs.unreported().filter { $0.outcome == "unavailable" }.count 31 | } 32 | 33 | var voicemailCount: Int { 34 | return (userStats?.voicemail ?? 0) + logs.unreported().filter { $0.outcome == "voicemail" || $0.outcome == "vm" }.count 35 | } 36 | 37 | var weeklyStreakCount: Int { 38 | // Eventually the server will calculate call streaks and report them when we pull 39 | // stats, but for now we are sourcing that data locally. 40 | let logDates = logs.all.map { $0.date } 41 | return StreakCounter(dates: logDates, referenceDate: Date()).weekly 42 | } 43 | 44 | var weeklyStreakMessage: String { 45 | // use the server weekly streak if available 46 | let weeklyStreakCount = self.userStats?.weeklyStreak ?? self.weeklyStreakCount 47 | 48 | switch weeklyStreakCount { 49 | case 0: 50 | return R.string.localizable.yourWeeklyStreakZero(weeklyStreakCount) 51 | case 1: 52 | return R.string.localizable.yourWeeklyStreakSingle() 53 | default: 54 | return R.string.localizable.yourWeeklyStreakMultiple(weeklyStreakCount) 55 | } 56 | } 57 | 58 | var impactMessage: String { 59 | switch self.numberOfCalls { 60 | case 0: 61 | return R.string.localizable.yourImpactZero(numberOfCalls) 62 | case 1: 63 | return R.string.localizable.yourImpactSingle(numberOfCalls) 64 | default: 65 | return R.string.localizable.yourImpactMultiple(numberOfCalls) 66 | } 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/InboxDetail.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InboxDetail.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 6/3/24. 6 | // Copyright © 2024 5calls. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct InboxDetail: View { 12 | @EnvironmentObject var store: Store 13 | @Environment(\.dismiss) var dismiss 14 | 15 | var message: InboxMessage 16 | var contactForMessage: Contact? { 17 | return store.state.contacts.filter({ $0.id == message.repID}).first 18 | } 19 | 20 | var body: some View { 21 | VStack { 22 | HStack { 23 | if let contact = contactForMessage { 24 | ContactListItem(contact: contact, showComplete: false) 25 | } else if let imageURL = message.imageURL, let contactName = message.contactName, let contactTitle = message.contactTitle { 26 | HStack { 27 | AsyncImage(url: imageURL, content: { image in 28 | image.resizable() 29 | .aspectRatio(contentMode: .fill) 30 | .mask { 31 | Circle() 32 | } 33 | }) { 34 | placeholder 35 | } 36 | .frame(width: 45, height: 45) 37 | .padding(.vertical, 8) 38 | .padding(.leading, 8) 39 | .padding(.trailing, 0) 40 | VStack(alignment: .leading) { 41 | Text(contactName) 42 | .font(.title3) 43 | .fontWeight(.semibold) 44 | .foregroundStyle(Color.primary) 45 | Text(contactTitle) 46 | .font(.footnote) 47 | .foregroundStyle(Color.primary) 48 | 49 | } 50 | .accessibilityElement(children: .combine) 51 | Spacer() 52 | } 53 | .padding(2) 54 | .accessibilityElement(children: .combine) 55 | } 56 | Button("", systemImage: "xmark") { 57 | self.dismiss() 58 | } 59 | } 60 | HStack { 61 | Text(message.title) 62 | .font(.title3) 63 | .fontWeight(.medium) 64 | .padding(.bottom, 4) 65 | Spacer() 66 | } 67 | HStack { 68 | Spacer() 69 | Text(message.date.formatted(date: .complete, time: .omitted)) 70 | .font(.footnote) 71 | .foregroundStyle(.secondary) 72 | }.padding(.bottom, 0) 73 | Text(message.description) 74 | .padding(.bottom, 4) 75 | if let moreInfoURL = message.moreInfoURL { 76 | Link(R.string.localizable.inboxDetailReadmore(), destination: moreInfoURL) 77 | .fontWeight(.medium) 78 | } 79 | Spacer() 80 | } 81 | .padding(.horizontal, 10) 82 | } 83 | 84 | var placeholder: some View { 85 | Image(systemName: "person.crop.circle") 86 | .resizable() 87 | .mask { 88 | Circle() 89 | } 90 | } 91 | } 92 | 93 | #Preview { 94 | let preview1State = { 95 | let state = AppState() 96 | state.contacts = [ 97 | Contact.housePreviewContact, 98 | Contact.senatePreviewContact1, 99 | Contact.senatePreviewContact2 100 | ] 101 | state.inboxRouter.selectedMessage = .houseMessage 102 | return state 103 | }() 104 | let store1 = Store(state: preview1State, middlewares: [appMiddleware()]) 105 | 106 | return Rectangle().sheet(isPresented: .constant(true)) { 107 | InboxDetail(message: .houseMessage).environmentObject(store1) 108 | .padding(.top, 20) 109 | .padding(.horizontal, 10) 110 | } 111 | } 112 | 113 | #Preview { 114 | let preview2State = { 115 | let state = AppState() 116 | state.contacts = [ 117 | Contact.housePreviewContact, 118 | Contact.senatePreviewContact1, 119 | Contact.senatePreviewContact2 120 | ] 121 | state.inboxRouter.selectedMessage = .whMessage 122 | return state 123 | }() 124 | let store2 = Store(state: preview2State, middlewares: [appMiddleware()]) 125 | 126 | return Rectangle().sheet(isPresented: .constant(true)) { 127 | InboxDetail(message: .whMessage).environmentObject(store2) 128 | .padding(.top, 20) 129 | .padding(.horizontal, 10) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/InboxMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InboxMessage.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 3/16/24. 6 | // Copyright © 2024 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct InboxMessage: Decodable, Identifiable, Equatable { 12 | let id: Int 13 | 14 | // the title of the message, usually the rep's name and what they voted for or against 15 | let title: String 16 | // a slightly longer description of what the legislation does 17 | let description: String 18 | // the date and time for this message, typically a vote date or a message date, used for ordering 19 | let date: Date 20 | // if this is a vote from a rep that we get back from the reps endpoint, the ID will be the same here, 21 | // usually a bioguide id for House and Senate. nil when we pass an override rep image url and name 22 | let repID: String? 23 | // an override message image url that we can pass for non-reps endpoint votes 24 | let imageURL: URL? 25 | // an override contact name for non-standard rep 26 | let contactName: String? 27 | // an override contact title for non-standard rep 28 | let contactTitle: String? 29 | // an indication that this was a vote for or against the position taken by 5 Calls, for styling 30 | let positive: Bool 31 | // an optional link where we can direct the user for learning more 32 | let moreInfoURL: URL? 33 | } 34 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/InboxRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InboxRouter.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 6/10/24. 6 | // Copyright © 2024 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class InboxRouter { 12 | var selectedMessage: InboxMessage? 13 | } 14 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/InboxVote.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InboxVote.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 5/26/24. 6 | // Copyright © 2024 5calls. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ContactInboxVote: View { 12 | var contact: Contact 13 | var message: InboxMessage 14 | 15 | var body: some View { 16 | HStack(alignment: .top) { 17 | ContactCircle(contact: contact) 18 | .frame(width: 20, height: 20) 19 | .padding(.top, 1) 20 | VStack(alignment: .leading) { 21 | Text(message.title) 22 | .font(.body) 23 | .fontWeight(.medium) 24 | Text(message.date.formatted(date: .abbreviated, time: .omitted)) 25 | .font(.footnote) 26 | .foregroundStyle(.secondary) 27 | } 28 | Spacer() 29 | Image(systemName: "chevron.right") 30 | .foregroundColor(.secondary) 31 | .accessibilityHidden(true) 32 | .padding(.top, 10) 33 | }.frame(minHeight: 40) 34 | } 35 | } 36 | 37 | struct GenericInboxVote: View { 38 | var message: InboxMessage 39 | 40 | var body: some View { 41 | HStack(alignment: .top) { 42 | AsyncImage(url: message.imageURL, content: { image in 43 | image.resizable() 44 | .aspectRatio(contentMode: .fill) 45 | .mask { 46 | Circle() 47 | } 48 | }) { 49 | placeholder 50 | } 51 | .frame(width: 20, height: 20) 52 | .padding(.top, 1) 53 | VStack(alignment: .leading) { 54 | Text(message.title) 55 | .font(.body) 56 | .fontWeight(.medium) 57 | Text(message.date.formatted(date: .abbreviated, time: .omitted)) 58 | .font(.footnote) 59 | .foregroundStyle(.secondary) 60 | } 61 | Spacer() 62 | Image(systemName: "chevron.right") 63 | .foregroundColor(.secondary) 64 | .accessibilityHidden(true) 65 | .padding(.top, 10) 66 | }.frame(minHeight: 40) 67 | } 68 | 69 | var placeholder: some View { 70 | Image(systemName: "person.crop.circle") 71 | .resizable() 72 | .mask { 73 | Circle() 74 | } 75 | } 76 | } 77 | 78 | #Preview { 79 | VStack(spacing: 10) { 80 | ContactInboxVote(contact: .housePreviewContact, message: .houseMessage) 81 | GenericInboxVote(message: .whMessage) 82 | }.padding(.horizontal, 10) 83 | } 84 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleDisplayName 8 | 5 Calls 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleURLTypes 22 | 23 | 24 | CFBundleTypeRole 25 | None 26 | CFBundleURLName 27 | auth0 28 | CFBundleURLSchemes 29 | 30 | $(PRODUCT_BUNDLE_IDENTIFIER) 31 | 32 | 33 | 34 | CFBundleVersion 35 | 71 36 | ITSAppUsesNonExemptEncryption 37 | 38 | LSRequiresIPhoneOS 39 | 40 | NSLocationAlwaysAndWhenInUseUsageDescription 41 | 5 Calls uses your location to find your representatives 42 | NSLocationWhenInUseUsageDescription 43 | 5 Calls uses your location to find your representatives 44 | OneSignalAppID 45 | $(ONE_SIGNAL_APP_ID) 46 | UIBackgroundModes 47 | 48 | remote-notification 49 | 50 | UILaunchStoryboardName 51 | LaunchScreen 52 | UIStatusBarStyle 53 | UIStatusBarStyleDefault 54 | UISupportedInterfaceOrientations 55 | 56 | UIInterfaceOrientationPortrait 57 | 58 | UISupportedInterfaceOrientations~ipad 59 | 60 | UIInterfaceOrientationPortrait 61 | UIInterfaceOrientationPortraitUpsideDown 62 | UIInterfaceOrientationLandscapeLeft 63 | UIInterfaceOrientationLandscapeRight 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Issue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Issue.swift 3 | // FiveCalls 4 | // 5 | // Created by Ben Scheirman on 1/31/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RswiftResources 11 | 12 | struct Issue: Identifiable, Decodable { 13 | let id: Int 14 | let meta: String 15 | let name: String 16 | let slug: String 17 | let reason: String 18 | let script: String 19 | let categories: [Category] 20 | let active: Bool 21 | let outcomeModels: [Outcome] 22 | let contactType: String 23 | let contactAreas: [String] 24 | let createdAt: Date 25 | 26 | var shareImageURL: URL { 27 | return URL(string: String(format: "https://api.5calls.org/v1/issue/%d/share/t",self.id))! 28 | } 29 | 30 | var shareURL: URL { 31 | return URL(string: String(format: "https://5calls.org/issue/%@/",self.slug.trimmingCharacters(in: .whitespacesAndNewlines)))! 32 | } 33 | 34 | // contactsForIssue takes a list of all contacts and returns a consistently sorted list of 35 | // contacts based on the areas for this issue 36 | func contactsForIssue(allContacts: [Contact]) -> [Contact] { 37 | var sortedContacts: [Contact] = [] 38 | 39 | for area in sortedContactAreas(areas: contactAreas) { 40 | sortedContacts.append(contentsOf: allContacts.filter({ area == $0.area })) 41 | } 42 | 43 | return sortedContacts 44 | } 45 | 46 | // sortedContactAreas takes a list of contact areas and orders them in our preferred order, 47 | // we should always order them properly on the server but let's do this to be sure 48 | func sortedContactAreas(areas: [String]) -> [String] { 49 | var contactAreas: [String] = [] 50 | 51 | // TODO: convert these to enums when they are parsed in json 52 | for area in ["StateLower", "StateUpper", "US House", "US Senate", "Governor", "AttorneyGeneral", "SecretaryOfState"] { 53 | if areas.contains(area) { 54 | contactAreas.append(area) 55 | } 56 | } 57 | 58 | // add any others at the end 59 | contactAreas.append(contentsOf: areas.filter({ !["StateLower", "StateUpper", "US House", "US Senate", "Governor", "AttorneyGeneral", "SecretaryOfState"].contains($0) })) 60 | 61 | return contactAreas 62 | } 63 | 64 | var markdownIssueReason: AttributedString { 65 | do { 66 | return try AttributedString(markdown: self.reason, options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)) 67 | } catch { 68 | // TODO: notify us somehow that markdown parsing failed 69 | return AttributedString("Could not parse issue markdown, email [hello@5calls.org](mailto:hello@5calls.org)") 70 | } 71 | } 72 | 73 | func markdownIssueScript(contact: Contact, location: UserLocation?) -> AttributedString { 74 | do { 75 | return try AttributedString(markdown: ScriptReplacements.replacing(script: self.script, contact: contact, location: location), options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)) 76 | } catch { 77 | // TODO: notify us somehow that markdown parsing failed 78 | return AttributedString("Could not parse script markdown, email [hello@5calls.org](mailto:hello@5calls.org)") 79 | } 80 | } 81 | } 82 | 83 | extension Issue: Equatable, Hashable { 84 | static func == (lhs: Issue, rhs: Issue) -> Bool { 85 | return lhs.id == rhs.id 86 | } 87 | 88 | func hash(into hasher: inout Hasher) { 89 | hasher.combine(id) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/IssueDetail.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IssueDetail.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 8/11/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct IssueDetail: View { 12 | @EnvironmentObject var store: Store 13 | 14 | let issue: Issue 15 | let contacts: [Contact] 16 | 17 | @State var showLocationSheet = false 18 | @State private var forceRefreshID = UUID() 19 | 20 | var body: some View { 21 | ScrollView { 22 | VStack(alignment: .leading, spacing: 0) { 23 | Text(issue.name) 24 | .font(.title2) 25 | .fontWeight(.medium) 26 | .padding(.top, 1) 27 | .padding(.bottom, 8) 28 | Text(issue.markdownIssueReason) 29 | .padding(.bottom, 16) 30 | .accentColor(.fivecallsDarkBlueText) 31 | if contacts.count > 0 { 32 | Text(R.string.localizable.repsListHeader()) 33 | .font(.caption) 34 | .foregroundColor(.secondary) 35 | .padding(.bottom, 2) 36 | .padding(.leading, 6) 37 | .accessibilityAddTraits(.isHeader) 38 | VStack(spacing: 0) { 39 | ForEach(contacts.numbered(), id: \.element.id) { contact in 40 | NavigationLink(value: IssueDetailNavModel(issue: issue, contacts: Array(contacts[contact.number.. Bool { 115 | return lhs.issue.id == rhs.issue.id && lhs.contacts.elementsEqual(rhs.contacts) 116 | } 117 | 118 | func hash(into hasher: inout Hasher) { 119 | hasher.combine(issue.id) 120 | hasher.combine(contacts.compactMap({$0.id}).joined()) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/IssueListItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IssueListItem.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 6/28/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct IssueListItem: View { 12 | let issue: Issue 13 | let contacts: [Contact] 14 | @Environment(\.dynamicTypeSize) private var dynamicTypeSize 15 | 16 | func usingRegularFonts() -> Bool { 17 | dynamicTypeSize < DynamicTypeSize.accessibility3 18 | } 19 | 20 | var body: some View { 21 | VStack { 22 | HStack { 23 | VStack(alignment: .leading) { 24 | Text(issue.name) 25 | .font(.title3) 26 | .fontWeight(.semibold) 27 | .foregroundColor(Color.fivecallsDarkBlueText) 28 | 29 | HStack(spacing: 0) { 30 | let contactsForIssue = contacts.isEmpty ? issue.contactAreas.flatMap({ area in 31 | Contact.placeholderContact(for: area) 32 | }) : issue.contactsForIssue(allContacts: contacts) 33 | ForEach(contactsForIssue.numbered()) { numberedContact in 34 | ContactCircle(contact: numberedContact.element, issueID: issue.id) 35 | .frame(width: usingRegularFonts() ? 20 : 40, height: usingRegularFonts() ? 20 : 40) 36 | .offset(x: -10 * CGFloat(numberedContact.number), y:0) 37 | } 38 | Text(repText) 39 | .font(.footnote) 40 | .foregroundColor(.primary) 41 | .offset(x: contactsForIssue.isEmpty ? 0 : 16 + (-10 * CGFloat(contactsForIssue.count)), y: 0) 42 | Spacer() 43 | } 44 | } 45 | .padding(.vertical, 10) 46 | } 47 | } 48 | } 49 | 50 | var repText: String { 51 | if issue.contactAreas.count == 0 { 52 | // we should never ship an issue with no contact areas, but handle the state anyway 53 | return R.string.localizable.noContacts() 54 | } else { 55 | let areas = issue.contactAreas.map({ a in AreaToNiceString(area: a) }).joined(separator: ", ") 56 | return R.string.localizable.callAreas(areas) 57 | } 58 | } 59 | } 60 | 61 | #Preview { 62 | let previewState = { 63 | let state = AppState() 64 | state.location = UserLocation(address: "3400 24th St, San Francisco, CA 94114", display: "San Francisco") 65 | return state 66 | }() 67 | 68 | 69 | return List { 70 | IssueListItem(issue: Issue.basicPreviewIssue, contacts: [Contact.housePreviewContact, Contact.senatePreviewContact1, Contact.senatePreviewContact2]) 71 | .padding(.horizontal, 10) 72 | IssueListItem(issue: Issue.multilinePreviewIssue, contacts: [Contact.housePreviewContact, Contact.senatePreviewContact1]) 73 | .padding(.horizontal, 10) 74 | IssueListItem(issue: Issue.extraLongPreviewIssue, contacts: [Contact.housePreviewContact]) 75 | .padding(.horizontal, 10) 76 | IssueListItem(issue: Issue.multilinePreviewIssue, contacts: []) 77 | .padding(.horizontal, 10) 78 | } 79 | .environmentObject(Store(state: previewState)) 80 | } 81 | 82 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/IssueRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IssueRouter.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 10/8/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | class IssueRouter { 12 | var path = NavigationPath() 13 | var selectedIssue: Issue? 14 | } 15 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/IssueSplitView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IssueSplitView.swift 3 | // FiveCalls 4 | // 5 | // Created by Christopher Selin on 11/1/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct IssueSplitView: View { 12 | @EnvironmentObject var store: Store 13 | 14 | var body: some View { 15 | TabView(selection: $store.state.selectedTab) { 16 | NavigationSplitView(columnVisibility: .constant(.all)) { 17 | Dashboard(selectedIssue: $store.state.issueRouter.selectedIssue) 18 | } detail: { 19 | NavigationStack(path: $store.state.issueRouter.path) { 20 | if let selectedIssue = store.state.issueRouter.selectedIssue { 21 | IssueDetail(issue: selectedIssue, 22 | contacts: selectedIssue.contactsForIssue(allContacts: store.state.contacts)) 23 | .toolbar(.hidden, for: .tabBar) 24 | .navigationDestination(for: IssueDetailNavModel.self) { idnm in 25 | IssueContactDetail(issue: idnm.issue, remainingContacts: idnm.contacts) 26 | } 27 | .navigationDestination(for: IssueDoneNavModel.self) { inm in 28 | IssueDone(issue: inm.issue) 29 | } 30 | } else { 31 | VStack(alignment: .leading) { 32 | Text(R.string.localizable.chooseIssuePlaceholder()) 33 | .font(.title2) 34 | .fontWeight(.medium) 35 | .foregroundColor(.secondary) 36 | Text(R.string.localizable.chooseIssueSubheading()) 37 | .font(.caption) 38 | .foregroundColor(.secondary) 39 | } 40 | } 41 | } 42 | } 43 | .navigationSplitViewStyle(.balanced) 44 | .tabItem({ Label(R.string.localizable.tabTopics(), systemImage: "phone.bubble.fill" ) }) 45 | .tag("topics") 46 | 47 | InboxView() 48 | .tabItem({ Label(R.string.localizable.tabReps(), systemImage: "person.crop.circle.fill.badge.checkmark") }) 49 | .tag("inbox") 50 | } 51 | } 52 | } 53 | 54 | struct IssueSplitView_Previews: PreviewProvider { 55 | static let previewState = { 56 | var state = AppState() 57 | state.issues = [ 58 | Issue.basicPreviewIssue, 59 | Issue.multilinePreviewIssue 60 | ] 61 | state.contacts = [ 62 | Contact.housePreviewContact, 63 | Contact.senatePreviewContact1, 64 | Contact.senatePreviewContact2 65 | ] 66 | return state 67 | }() 68 | 69 | static let store = Store(state: previewState, middlewares: [appMiddleware()]) 70 | 71 | static var previews: some View { 72 | IssueSplitView().environmentObject(store) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/IssuesManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IssuesManager.swift 3 | // FiveCalls 4 | // 5 | // Created by Ben Scheirman on 2/2/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum LoadResult { 12 | case success 13 | case serverError(Error) 14 | case offline 15 | } 16 | 17 | class IssuesManager { 18 | 19 | private let queue: OperationQueue 20 | 21 | public var issues: [Issue] = [] 22 | 23 | init() { 24 | queue = .main 25 | } 26 | 27 | func issue(withId id: Int64) -> Issue? { 28 | return issues.first(where: { $0.id == id }) 29 | } 30 | 31 | func fetchIssues(completion: @escaping (LoadResult) -> Void) { 32 | let operation = FetchIssuesOperation() 33 | operation.completionBlock = { [weak self, weak operation] in 34 | if let issues = operation?.issuesList { 35 | self?.issues = issues 36 | DispatchQueue.main.async { 37 | completion(.success) 38 | } 39 | } else { 40 | let error = operation?.error 41 | print("Could not load issues: \(error?.localizedDescription ?? "")..") 42 | 43 | DispatchQueue.main.async { 44 | if let e = error { 45 | completion(.serverError(e)) 46 | } else { 47 | // souldn't happen, but let's just assume connection error 48 | completion(.offline) 49 | } 50 | } 51 | } 52 | } 53 | queue.addOperation(operation) 54 | } 55 | 56 | public func issue(withSlug slug: String) -> Issue? { 57 | return issues.first(where: {$0.slug == slug}) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/IssuesViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IssuesViewModel.swift 3 | // FiveCalls 4 | // 5 | // Created by Indrajit on 17/10/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol IssuesViewModel { 12 | 13 | var issues: [Issue] { get } 14 | 15 | init(issues:[Issue]) 16 | func numberOfSections() -> Int 17 | func numberOfRowsInSection(section: Int) -> Int 18 | func hasNoData() -> Bool 19 | func issueForIndexPath(indexPath: IndexPath) -> Issue 20 | func titleForHeaderInSection(section: Int) -> String 21 | } 22 | 23 | extension IssuesViewModel { 24 | var categorizedIssues: [CategorizedIssuesViewModel] { 25 | var categoryViewModels = Set() 26 | for issue in issues { 27 | for category in issue.categories { 28 | 29 | if let categorized = categoryViewModels.first(where: { $0.category == category }) { 30 | categorized.issues.append(issue) 31 | } else { 32 | categoryViewModels.insert(CategorizedIssuesViewModel(category: category, issues: [issue])) 33 | } 34 | } 35 | } 36 | return Array(categoryViewModels).sorted(by: { $0.category < $1.category }) 37 | } 38 | } 39 | 40 | // Shows all issues - grouped by categories. 41 | struct AllIssuesViewModel: IssuesViewModel { 42 | let issues: [Issue] 43 | 44 | init(issues: [Issue]) { 45 | self.issues = issues 46 | } 47 | 48 | func numberOfSections() -> Int { 49 | // As many section as there are unique categories. 50 | return categorizedIssues.count 51 | } 52 | 53 | func numberOfRowsInSection(section: Int) -> Int { 54 | return categorizedIssues[section].issues.count 55 | } 56 | 57 | func hasNoData() -> Bool { 58 | return categorizedIssues.count == 0 59 | } 60 | 61 | func issueForIndexPath(indexPath: IndexPath) -> Issue { 62 | return categorizedIssues[indexPath.section].issues[indexPath.row] 63 | } 64 | 65 | func titleForHeaderInSection(section: Int) -> String { 66 | // Category name as section header. 67 | return categorizedIssues[section].name 68 | } 69 | } 70 | 71 | // Shows only the active issues. 72 | struct ActiveIssuesViewModel: IssuesViewModel { 73 | private let activeIssues: [Issue] 74 | let issues: [Issue] 75 | 76 | init(issues: [Issue]) { 77 | self.issues = issues 78 | activeIssues = issues.filter { $0.active } 79 | } 80 | 81 | func numberOfSections() -> Int { 82 | return 1 83 | } 84 | 85 | func numberOfRowsInSection(section: Int) -> Int { 86 | return activeIssues.count 87 | } 88 | 89 | func hasNoData() -> Bool { 90 | return activeIssues.count == 0 91 | } 92 | 93 | func issueForIndexPath(indexPath: IndexPath) -> Issue { 94 | return activeIssues[indexPath.row] 95 | } 96 | 97 | func titleForHeaderInSection(section: Int) -> String { 98 | return R.string.localizable.whatsImportantTitle() 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/JSONSerializable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSONSerializable.swift 3 | // FiveCalls 4 | // 5 | // Created by Ben Scheirman on 1/31/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | typealias JSONDictionary = [String:Any] 12 | 13 | protocol JSONSerializable { 14 | init?(dictionary: JSONDictionary) 15 | } 16 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/LocationCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationCoordinator.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 8/7/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreLocation 11 | import os 12 | 13 | enum LocationCoordinatorError: Error { 14 | case NoLocationsReturned 15 | case Unauthorized 16 | case LocationManagerError(Error) 17 | case Unknown 18 | } 19 | 20 | class LocationCoordinator: NSObject, CLLocationManagerDelegate { 21 | private let manager = CLLocationManager() 22 | private var locationContinuation: CheckedContinuation? 23 | 24 | override init() { 25 | super.init() 26 | manager.delegate = self 27 | } 28 | 29 | func getLocation() async throws -> CLLocation { 30 | return try await withCheckedThrowingContinuation({ continuation in 31 | locationContinuation = continuation 32 | DispatchQueue.global(qos: .userInitiated).async { [weak self] () -> Void in 33 | switch self?.manager.authorizationStatus { 34 | case .denied, .restricted: 35 | self?.locationContinuation?.resume(throwing: LocationCoordinatorError.Unauthorized) 36 | self?.locationContinuation = nil 37 | case .authorizedAlways, .authorizedWhenInUse: 38 | self?.manager.requestLocation() 39 | default: 40 | self?.manager.requestWhenInUseAuthorization() 41 | } 42 | } 43 | }) 44 | } 45 | 46 | func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { 47 | // print("new location: \(locations)") 48 | if let location = locations.last { 49 | locationContinuation?.resume(with: .success(location)) 50 | locationContinuation = nil 51 | } else { 52 | locationContinuation?.resume(with: .failure(LocationCoordinatorError.NoLocationsReturned)) 53 | locationContinuation = nil 54 | } 55 | } 56 | 57 | func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { 58 | // print("loc manager failed: \(error)") 59 | locationContinuation?.resume(with: .failure(LocationCoordinatorError.Unknown)) 60 | locationContinuation = nil 61 | 62 | } 63 | 64 | func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { 65 | // print("change authorization: \(manager.authorizationStatus)") 66 | if manager.authorizationStatus == .authorizedWhenInUse { 67 | DispatchQueue.global(qos: .userInitiated).async { () -> Void in 68 | manager.requestLocation() 69 | } 70 | } else if manager.authorizationStatus == .denied { 71 | locationContinuation?.resume(with: .failure(LocationCoordinatorError.Unauthorized)) 72 | locationContinuation = nil 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/LocationHeader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationHeader.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 8/4/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct LocationHeader: View { 12 | let location: UserLocation? 13 | let isSplit: Bool 14 | let fetchingContacts: Bool 15 | @Environment(\.dynamicTypeSize) private var dynamicTypeSize 16 | 17 | func usingRegularFonts() -> Bool { 18 | dynamicTypeSize < DynamicTypeSize.accessibility3 19 | } 20 | 21 | var body: some View { 22 | HStack { 23 | Spacer() 24 | if fetchingContacts { 25 | SwiftUI.ProgressView() 26 | } 27 | if location == nil { 28 | unsetLocationView 29 | .background { 30 | RoundedRectangle(cornerRadius: 20, style: .continuous) 31 | .fill(Color(.quaternaryLabel)) 32 | } 33 | } else { 34 | locationView 35 | .background { 36 | RoundedRectangle(cornerRadius: 20, style: .continuous) 37 | .fill(Color(.quaternaryLabel)) 38 | } 39 | } 40 | Spacer() 41 | } 42 | } 43 | 44 | var locationView: some View { 45 | HStack { 46 | VStack(alignment: .leading) { 47 | Text(R.string.localizable.yourLocationIs) 48 | .font(.footnote) 49 | if isSplit { 50 | Text( 51 | "\(Image(systemName: "exclamationmark.triangle")) \(location!.locationDisplay)" 52 | ) 53 | .font(.system(.title3)) 54 | .fontWeight(.medium) 55 | } else { 56 | Text(location!.locationDisplay) 57 | .font(.system(.title3)) 58 | .fontWeight(.medium) 59 | } 60 | } 61 | .padding(.horizontal, usingRegularFonts() ? 15 : 5) 62 | .padding(.vertical, 10) 63 | if usingRegularFonts() { 64 | Image(systemName: "location.circle") 65 | .imageScale(.large) 66 | .symbolRenderingMode(.hierarchical) 67 | .font(.title3) 68 | .padding(.trailing) 69 | .padding(.leading, 7) 70 | } 71 | } 72 | .accessibilityElement(children: .ignore) 73 | .accessibilityLabel(Text("\(R.string.localizable.yourLocationIs()) \(location!.locationDisplay)")) 74 | .accessibilityAddTraits(.isButton) 75 | } 76 | 77 | var unsetLocationView: some View { 78 | HStack { 79 | VStack(alignment: .leading) { 80 | Text(R.string.localizable.setYourLocation) 81 | .font(.system(.title3)) 82 | .fontWeight(.medium) 83 | } 84 | .padding(.leading) 85 | .padding(.vertical, 10) 86 | Image(systemName: "location.circle") 87 | .imageScale(.large) 88 | .symbolRenderingMode(.hierarchical) 89 | .font(.title3) 90 | .padding(.trailing) 91 | .padding(.leading, 7) 92 | } 93 | .accessibilityElement(children: .ignore) 94 | .accessibilityLabel(Text(R.string.localizable.setYourLocation)) 95 | .accessibilityAddTraits(.isButton) 96 | } 97 | } 98 | 99 | struct LocationHeader_Previews: PreviewProvider { 100 | static var previews: some View { 101 | VStack { 102 | LocationHeader(location: nil, isSplit: false, fetchingContacts: true) 103 | LocationHeader(location: nil, isSplit: true, fetchingContacts: false) 104 | LocationHeader( 105 | location: UserLocation(address: "19444"), 106 | isSplit: false, fetchingContacts: false 107 | ) 108 | .frame(maxWidth: 250) 109 | LocationHeader( 110 | location: UserLocation(address: "48184"), 111 | isSplit: true, fetchingContacts: false 112 | ) 113 | .frame(maxWidth: 250) 114 | Spacer() 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/MainHeader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainHeader.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 3/16/24. 6 | // Copyright © 2024 5calls. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct MainHeader: View { 12 | @EnvironmentObject var store: Store 13 | 14 | @State var showLocationSheet = false 15 | 16 | var body: some View { 17 | HStack { 18 | MenuView(showingWelcomeScreen: store.state.showWelcomeScreen) 19 | 20 | LocationHeader(location: store.state.location, 21 | isSplit: store.state.isSplitDistrict, 22 | fetchingContacts: store.state.fetchingContacts) 23 | .padding(.bottom, 10) 24 | .onTapGesture { 25 | showLocationSheet.toggle() 26 | } 27 | .sheet(isPresented: $showLocationSheet) { 28 | LocationSheet() 29 | .presentationDetents([.medium]) 30 | .presentationDragIndicator(.visible) 31 | .padding(.top, 40) 32 | Spacer() 33 | } 34 | 35 | Image(.fivecallsStars) 36 | .accessibilityHidden(true) 37 | } 38 | } 39 | } 40 | 41 | #Preview { 42 | MainHeader() 43 | } 44 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/NewsletterSignup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewsletterSignup.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 11/21/24. 6 | // Copyright © 2024 5calls. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | 12 | struct NewsletterSignup: View { 13 | @State var email: String = "" 14 | @State var errorString: String? 15 | var onDismiss: () -> Void 16 | var onSubmit: (String) -> Void 17 | 18 | var body: some View { 19 | VStack(alignment: .leading, spacing: 4) { 20 | Text(R.string.localizable.newsletterHeader) 21 | .font(.headline) 22 | .foregroundStyle(.white) 23 | if errorString != nil { 24 | Text(errorString!) 25 | .font(.caption) 26 | .fontWeight(.semibold) 27 | .foregroundStyle(.red) 28 | .padding(.bottom, 4) 29 | } else { 30 | Text(R.string.localizable.newsletterSubhead()) 31 | .font(.caption) 32 | .foregroundStyle(.white) 33 | .padding(.bottom, 4) 34 | } 35 | TextField( 36 | "", 37 | text: $email, 38 | prompt: Text(R.string.localizable.newsletterEmailPlaceholder()) 39 | .foregroundColor(.fivecallsDarkGray) 40 | ) 41 | .font(.headline) 42 | .padding(.horizontal, 4) 43 | .padding(.vertical, 8) 44 | .foregroundColor(.black) 45 | .background(.white) 46 | .clipShape(RoundedRectangle(cornerRadius: 4)) 47 | .keyboardType(.emailAddress) 48 | .autocorrectionDisabled() 49 | .textInputAutocapitalization(.never) 50 | .onChange(of: email) { newValue in 51 | // reset the error state when the user starts typing again 52 | errorString = nil 53 | } 54 | HStack { 55 | Button(action: onDismiss, 56 | label: { 57 | Label( 58 | R.string.localizable.newsletterDismiss(), 59 | systemImage: "nosign" 60 | ) 61 | .padding(.vertical, 6) 62 | .frame(maxWidth: .infinity) 63 | }).background(.white).clipShape( 64 | RoundedRectangle(cornerRadius: 8) 65 | ) 66 | Button(action: { 67 | if !isValidEmail(email) { 68 | errorString = R.string.localizable 69 | .newsletterInvalidEmail() 70 | } else { 71 | onSubmit(email) 72 | } 73 | }, label: { 74 | Label( 75 | R.string.localizable.newsletterSubscribe(), 76 | systemImage: "paperplane" 77 | ).padding(.vertical, 6) 78 | .frame(maxWidth: .infinity) 79 | }).background(.white).clipShape( 80 | RoundedRectangle(cornerRadius: 8) 81 | ) 82 | }.padding(.top, 6) 83 | }.padding() 84 | .background(.fivecallsDarkBlue) 85 | .clipShape(RoundedRectangle(cornerRadius: 10)) 86 | .padding() 87 | } 88 | } 89 | 90 | func isValidEmail(_ email: String) -> Bool { 91 | let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" 92 | let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex) 93 | return emailPredicate.evaluate(with: email) 94 | } 95 | 96 | #Preview { 97 | NewsletterSignup(onDismiss: {}, onSubmit: { _ in }) 98 | NewsletterSignup(email: "some@email.com", onDismiss: {}, onSubmit: { _ in }) 99 | NewsletterSignup(errorString: "Invalid email", onDismiss: {}, onSubmit: { _ in }) 100 | } 101 | 102 | #Preview { 103 | NewsletterSignup(onDismiss: {}, onSubmit: { _ in }).preferredColorScheme(.dark) 104 | NewsletterSignup(email: "some@email.com", onDismiss: {}, onSubmit: { _ in }) 105 | } 106 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/NotificationNames.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationNames.swift 3 | // FiveCalls 4 | // 5 | // Created by Christopher Brandow on 2/1/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Notification.Name { 12 | static let locationChanged = Notification.Name("locationChanged") 13 | static let callMade = Notification.Name("callMade") 14 | } 15 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Numbered.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Numbered.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 8/3/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | // from Ole Begemann 12 | // https://oleb.net/2020/foreach-enumerated/ 13 | // we use this to get an index as well as a value in a ForEach loop in SwiftUI 14 | 15 | @dynamicMemberLookup 16 | struct Numbered { 17 | var number: Int 18 | var element: Element 19 | 20 | subscript(dynamicMember keyPath: WritableKeyPath) -> T { 21 | get { element[keyPath: keyPath] } 22 | set { element[keyPath: keyPath] = newValue } 23 | } 24 | } 25 | 26 | extension Numbered: Identifiable where Element: Identifiable { 27 | var id: Element.ID { element.id } 28 | } 29 | 30 | extension Sequence { 31 | func numbered(startingAt start: Int = 0) -> [Numbered] { 32 | zip(start..., self) 33 | .map { Numbered(number: $0.0, element: $0.1) } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Outcome.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Outcome.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 9/17/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | // Outcomes are the buttons that a user sees at the end of a call 12 | // we use the label field to display the localized text for the button 13 | // and the status field to add to the impact data 14 | struct Outcome: Decodable { 15 | let label: String 16 | let status: String 17 | } 18 | 19 | extension Outcome: Identifiable { 20 | var id: Int { 21 | return label.hashValue 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/OutcomesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OutcomesView.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 10/12/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct OutcomesView: View { 12 | let outcomes: [Outcome] 13 | let report: (Outcome) -> () 14 | 15 | var body: some View { 16 | LazyVGrid(columns: [GridItem(.flexible()),GridItem(.flexible())]) { 17 | ForEach(outcomes) { outcome in 18 | PrimaryButton(title: ContactLog.localizedOutcomeForStatus(status: outcome.status)) 19 | .accessibilityAddTraits(.isButton) 20 | .onTapGesture { 21 | report(outcome) 22 | } 23 | } 24 | } 25 | } 26 | } 27 | 28 | #Preview { 29 | OutcomesView(outcomes: [Outcome(label: "Left Voicemail", status: "vm"),Outcome(label: "No", status: "no"),Outcome(label: "Maybe", status: "maybe")], report: { _ in }) 30 | .padding() 31 | } 32 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/PopoverTip.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PopoverTip.swift 3 | // FiveCalls 4 | // 5 | // Created by Christopher Selin on 10/24/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import TipKit 11 | 12 | @available(iOS 17.0, *) 13 | struct PopoverTip: Tip { 14 | var title: Text 15 | var message: Text? 16 | var image: Image? 17 | 18 | var options: [Option] { 19 | Tips.MaxDisplayCount(3) 20 | } 21 | } 22 | 23 | extension View { 24 | func popoverTipIfApplicable(showingWelcomeScreen: Bool, 25 | title: Text, 26 | message: Text?) 27 | -> some View 28 | { 29 | if #available(iOS 17, *) { 30 | if showingWelcomeScreen { 31 | AnyView(self) 32 | } else { 33 | AnyView(self 34 | .popoverTip( 35 | PopoverTip( 36 | title: title, 37 | message: message 38 | ), 39 | arrowEdge: .top 40 | )) 41 | } 42 | } else { 43 | AnyView(self) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/PreviewContacts.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewContacts.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 8/2/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Contact { 12 | static let housePreviewContact = Contact(id: "1234", area: "US House", name: "Housy McHouseface", party: "Democrat", phone: "415-555-1212", photoURL: URL(string: "https://images.5calls.org/senate/256/S001227.jpg")!, fieldOffices: [AreaOffice(city: "San Francisco", phone: "415-513-1111"),AreaOffice(city: "San Diego", phone: "415-513-2222")]) 13 | static let senatePreviewContact1 = Contact(id: "12345", area: "US Senate", name: "Senatey McDefinitelyOld", party: "Democrat", phone: "415-555-1212") 14 | static let senatePreviewContact2 = Contact(id: "12346", area: "US Senate", name: "Senatey McShouldHaveRetired", party: "Democrat", phone: "415-555-1212") 15 | static let weirdShapeImagePreviewContact = Contact(id: "12347", area: "US Senate", name: "Senatey McShouldHaveRetired", party: "Democrat", phone: "415-555-1212", photoURL: URL(string: "https://www.assembly.ca.gov/sites/assembly.ca.gov/files/memberphotos/ad17_haney.jpg")!) 16 | static let unknownMayorPreviewContact = Contact(id: "1234", area: "Mayor", name: "Mayor McMayorface", party: "Democrat", phone: "415-555-1212", photoURL: URL(string: "https://images.5calls.org/senate/256/S001227.jpg")!, fieldOffices: [AreaOffice(city: "San Francisco", phone: "415-513-1111"),AreaOffice(city: "San Diego", phone: "415-513-2222")]) 17 | } 18 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/PreviewIssues.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewIssues.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 6/28/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Issue { 12 | static let issueReason = """ 13 | Congress is currently considering [the RESTRICT Act](https://www.warner.senate.gov/public/index.cfm/2023/3/senators-introduce-bipartisan-bill-to-tackle-national-security-threats-from-foreign-tech), [(S.686)](https://www.congress.gov/bill/118th-congress/senate-bill/686) a bill that purports to protect Americans by restricting access to apps and websites that could pose a threat to national security. 14 | 15 | ~this is very important.~ 16 | 17 | ~~this is also very important.~~ 18 | 19 | Demand your Senators oppose the RESTRICT Act to ensure a free and fair internet. 20 | """ 21 | 22 | static let issueScript = """ 23 | Hi, my name is **[NAME]** and I’m a constituent from [CITY, ZIP]. 24 | 25 | I'm calling to demand [REP/SEN NAME] oppose S. 686, the RESTRICT Act. The legislation would do nothing to protect Americans and would give potential future Presidents more tools to abuse their power. 26 | 27 | Thank you for your time and consideration. 28 | 29 | **IF LEAVING VOICEMAIL:** Please leave your full street address to ensure your call is tallied. 30 | """ 31 | 32 | static let basicPreviewIssue = Issue(id: 813, meta: "", name: "Support the Act", slug: "support-act-slug", reason: Issue.issueReason, script: Issue.issueScript, categories: [Category(name: "Budget")], active: true, outcomeModels: [Outcome(label: "Contacted", status: "contact"), Outcome(label: "Voicemail", status: "voicemail")], contactType: "reps", contactAreas: ["US House", "US Senate"], createdAt: Date(timeIntervalSince1970: 1688015904)) 33 | static let multilinePreviewIssue = Issue(id: 812, meta: "", name: "Support the Act whose name is quite long", slug: "support-act-slug2", reason: Issue.issueReason, script: Issue.issueScript, categories: [Category(name: "Environment")], active: true, outcomeModels: [Outcome(label: "Contacted", status: "contact"), Outcome(label: "Voicemail", status: "voicemail")], contactType: "reps", contactAreas: ["US House", "US Senate"], createdAt: Date(timeIntervalSince1970: 1688015904)) 34 | static let extraLongPreviewIssue = Issue(id: 811, meta: "", name: "Call for supportive action in a made up region with extra long details", slug: "support-act-slug2", reason: Issue.issueReason, script: Issue.issueScript, categories: [Category(name: "Environment")], active: true, outcomeModels: [Outcome(label: "Contacted", status: "contact"), Outcome(label: "Voicemail", status: "voicemail")], contactType: "reps", contactAreas: ["US House", "US Senate"], createdAt: Date(timeIntervalSince1970: 1688015904)) 35 | } 36 | 37 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/PreviewMessages.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewMessages.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 5/16/24. 6 | // Copyright © 2024 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension InboxMessage { 12 | static let houseMessage = InboxMessage(id: 1, title: "Rep McHouseface voted to preserve net neutrality.", description: "H.R 1111 was proposed as a way to preserve net neutrality by requiring the FCC tell all ISPs that they should treat all internet content equally and not provide degraded or preferred service for any particular use or website.", date: Date().addingTimeInterval(-3600), repID: "1234", imageURL: nil, contactName: nil, contactTitle: nil, positive: true, moreInfoURL: nil) 13 | static let senate1Message = InboxMessage(id: 2, title: "Sen. McOldface voted against net neutrality.", description: "H.R 1111 was proposed as a way to preserve net neutrality by requiring the FCC tell all ISPs that they should treat all internet content equally and not provide degraded or preferred service for any particular use or website.", date: Date().addingTimeInterval(-3600), repID: "12345", imageURL: nil, contactName: nil, contactTitle: nil, positive: false, moreInfoURL: nil) 14 | static let senate2Message = InboxMessage(id: 3, title: "Sen. McShouldHaveRetired voted against net neutrality.", description: "H.R 1111 was proposed as a way to preserve net neutrality by requiring the FCC tell all ISPs that they should treat all internet content equally and not provide degraded or preferred service for any particular use or website.", date: Date().addingTimeInterval(-3600), repID: "12346", imageURL: nil, contactName: nil, contactTitle: nil, positive: false, moreInfoURL: nil) 15 | static let whMessage = InboxMessage(id: 4, title: "President Joe Biden signed net neutrality into law", description: "H.R 1111 was proposed as a way to preserve net neutrality by requiring the FCC tell all ISPs that they should treat all internet content equally and not provide degraded or preferred service for any particular use or website.", date: Date().addingTimeInterval(-3600), repID: nil, imageURL: URL(string: "https://upload.wikimedia.org/wikipedia/commons/thumb/3/36/Seal_of_the_President_of_the_United_States.svg/640px-Seal_of_the_President_of_the_United_States.svg.png")!, contactName: "President Biden", contactTitle: "US President", positive: true, moreInfoURL: URL(string: "https://whitehouse.gov")!) 16 | } 17 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/PrimaryButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PrimaryButton.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 8/28/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct PrimaryButton: View { 12 | let title: String 13 | var systemImageName: String? 14 | 15 | var bgColor: Color = .fivecallsDarkBlue 16 | 17 | var body: some View { 18 | HStack { 19 | Text(title) 20 | .font(.title3) 21 | .fontWeight(.semibold) 22 | .foregroundColor(.white) 23 | .lineLimit(1) 24 | if let systemImageName { 25 | Image(systemName: systemImageName) 26 | .foregroundColor(.white) 27 | } 28 | } 29 | .accessibilityElement(children: .combine) 30 | .padding(.vertical) 31 | .frame(maxWidth: .infinity) 32 | .background { 33 | RoundedRectangle(cornerRadius: 6) 34 | .foregroundColor(bgColor) 35 | } 36 | } 37 | } 38 | 39 | #Preview { 40 | VStack { 41 | PrimaryButton(title: "See your script", systemImageName: "megaphone.fill") 42 | .padding() 43 | PrimaryButton(title: "See your script") 44 | .padding(.horizontal, 100) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/ProtocolMock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProtocolMock.swift 3 | // FiveCallsUITests 4 | // 5 | // Created by Nick O'Neill on 12/19/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class ProtocolMock: URLProtocol { 12 | override class func canInit(with request: URLRequest) -> Bool { 13 | return true 14 | } 15 | 16 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 17 | return request 18 | } 19 | 20 | override func startLoading() { 21 | let key = "\(request.httpMethod!):\(request.url!.path)" 22 | 23 | if let fixturePath = ProcessInfo.processInfo.environment[key] ?? UserDefaults.standard.string(forKey: "mock-\(key)") { 24 | let responseData = loadJSONFixtureData(path: fixturePath) 25 | 26 | self.client?.urlProtocol(self, didLoad: responseData) 27 | self.client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed) 28 | } else { 29 | self.client?.urlProtocol(self, didFailWithError: ProtocolMockError.noFixtureForRequest) 30 | } 31 | 32 | self.client?.urlProtocolDidFinishLoading(self) 33 | } 34 | 35 | override func stopLoading() { } 36 | 37 | private func loadJSONFixtureData(path: String) -> Data { 38 | guard FileManager.default.fileExists(atPath: path) else { 39 | fatalError("JSON Fixture not found at path: \(path)") 40 | } 41 | 42 | return try! Data(contentsOf: URL(fileURLWithPath: path)) 43 | } 44 | } 45 | 46 | enum ProtocolMockError: Error { 47 | case noFixtureForRequest 48 | } 49 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/RatingPromptCounter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RatingPromptCounter.swift 3 | // FiveCalls 4 | // 5 | // Created by Abizer Nasir on 09/10/2017. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct RatingPromptCounter { 12 | private static let threshold = 5 13 | 14 | static func increment(handler: () -> Void) { 15 | let defaults = UserDefaults.standard 16 | let key = UserDefaultsKey.countOfCallsForRatingPrompt.rawValue 17 | let count = defaults.integer(forKey: key) + 1 18 | 19 | guard count <= threshold else { return } 20 | 21 | defaults.set(count, forKey: key) 22 | 23 | if count == threshold { 24 | handler() 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/ReportOutcomeOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReportOutcomeOperation.swift 3 | // FiveCalls 4 | // 5 | // Created by Ben Scheirman on 2/4/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class ReportOutcomeOperation: BaseOperation, @unchecked Sendable { 12 | 13 | //Input properties 14 | var log: ContactLog 15 | var outcome: Outcome 16 | 17 | //Output properties 18 | var httpResponse: HTTPURLResponse? 19 | var error: Error? 20 | 21 | init(log: ContactLog, outcome: Outcome) { 22 | self.log = log 23 | self.outcome = outcome 24 | } 25 | 26 | var url: URL { 27 | return URL(string: "https://api.5calls.org/v1/report")! 28 | } 29 | 30 | override func execute() { 31 | let config = URLSessionConfiguration.default 32 | let session = URLSession(configuration: config) 33 | 34 | // rather than avoiding network calls during debug, 35 | // indicate they shouldn't be included in counts 36 | let via: String 37 | #if DEBUG 38 | via = "test" 39 | #else 40 | via = "ios" 41 | #endif 42 | 43 | var request = buildRequest(forURL: url) 44 | request.httpMethod = "POST" 45 | request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 46 | 47 | var queryParams = [ 48 | "result": outcome.label, 49 | "contactid": log.contactId, 50 | "issueid": log.issueId, 51 | "phone": log.phone, 52 | "via": via, 53 | "callerid": AnalyticsManager.shared.callerID 54 | ] 55 | 56 | // Add calling group if it exists 57 | if let callingGroup = UserDefaults.standard.string(forKey: UserDefaultsKey.callingGroup.rawValue), 58 | !callingGroup.isEmpty { 59 | queryParams["group"] = callingGroup 60 | } 61 | 62 | let query = queryParams.map { "\($0)=\($1)" }.joined(separator: "&") 63 | guard let data = query.data(using: .utf8) else { 64 | print("error creating HTTP POST body") 65 | return 66 | } 67 | request.httpBody = data 68 | let task = session.dataTask(with: request) { (data, response, error) in 69 | if let e = error { 70 | self.error = e 71 | } else { 72 | let http = response as! HTTPURLResponse 73 | self.httpResponse = http 74 | if let _ = data, http.statusCode == 200 { 75 | print("sent report successfully") 76 | var logs = ContactLogs.load() 77 | logs.markReported(self.log) 78 | logs.save() 79 | } 80 | } 81 | self.finish() 82 | } 83 | task.resume() 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/ScriptReplacements.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScriptReplacements.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 12/12/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RegexBuilder 11 | 12 | struct ScriptReplacements { 13 | static func replacing(script: String, contact: Contact, location: UserLocation?) -> String { 14 | var replacedScript = ScriptReplacements.chooseSubscript(script: script, contact: contact) 15 | 16 | replacedScript = ScriptReplacements.replacingContact(script: replacedScript, contact: contact) 17 | 18 | if let location { 19 | replacedScript = ScriptReplacements.replacingLocation(script: replacedScript, location: location) 20 | } 21 | 22 | return replacedScript 23 | } 24 | 25 | static func chooseSubscript(script: String, contact: Contact) -> String { 26 | let houseIntroPattern = /\*{2}WHEN CALLING HOUSE:\*{2}\n/ 27 | let senateIntroPattern = /\*{2}WHEN CALLING SENATE:\*{2}\n/ 28 | 29 | func wholeRegex(_ introPattern: Regex) -> Regex { 30 | return Regex { 31 | introPattern 32 | OneOrMore(.anyNonNewline) 33 | OneOrMore(.newlineSequence) 34 | } 35 | } 36 | if contact.area == "US House" || contact.area == "House" { 37 | let replacedScript = script.replacing(Regex(houseIntroPattern), with: "") 38 | return replacedScript.replacing(wholeRegex(senateIntroPattern), with: "") 39 | } else if contact.area == "US Senate" || contact.area == "Senate" { 40 | let replacedScript = script.replacing(Regex(senateIntroPattern), with: "") 41 | return replacedScript.replacing(wholeRegex(houseIntroPattern), with: "") 42 | } 43 | return script 44 | } 45 | 46 | static func replacingContact(script: String, contact: Contact) -> String { 47 | let pattern = /\[REP\/SEN NAME\]|\[SENATOR\/REP NAME\]|\[SENATOR NAME\]|\[REPRESENTATIVE NAME\]/ 48 | let template = contact.title.map { $0 + " " + contact.name } ?? contact.name 49 | return script.replacing(Regex(pattern), with: template) 50 | } 51 | 52 | static func replacingLocation(script: String, location: UserLocation) -> String { 53 | let pattern = /\[CITY,\s?ZIP\]|\[CITY,\s?STATE\]/ 54 | return script.replacing(Regex(pattern), with: location.locationDisplay) 55 | } 56 | } 57 | 58 | extension Contact { 59 | var title: String? { 60 | switch self.area { 61 | case "US House", "House": 62 | return R.string.localizable.titleUsHouse() 63 | case "US Senate", "Senate": 64 | return R.string.localizable.titleUsSenate() 65 | case "StateLower", "StateUpper": 66 | return R.string.localizable.titleStateRep() 67 | case "Governor": 68 | return R.string.localizable.titleGovernor() 69 | case "AttorneyGeneral": 70 | return R.string.localizable.titleAttorneyGeneral() 71 | case "SecretaryOfState": 72 | return R.string.localizable.titleSecretaryOfState() 73 | default: 74 | // return nothing for unknown 75 | return nil 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/StatsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatsViewModel.swift 3 | // FiveCalls 4 | // 5 | // Created by Ben Scheirman on 1/30/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct StatsViewModel { 12 | let numberOfCalls: Int 13 | 14 | var formattedNumberOfCalls: String! { 15 | let numberFormatter = NumberFormatter() 16 | 17 | numberFormatter.formatterBehavior = .behavior10_4 18 | numberFormatter.numberStyle = .decimal 19 | 20 | return numberFormatter.string(from: NSNumber(integerLiteral: numberOfCalls)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/StreakCounter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StreakCounter.swift 3 | // FiveCalls 4 | // 5 | // Created by Nikrad Mahdi on 3/10/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct StreakCounter { 12 | 13 | enum IntervalType { 14 | case Daily 15 | case Weekly 16 | } 17 | 18 | let dates: [Date] 19 | let referenceDate: Date 20 | 21 | init(dates: [Date], referenceDate: Date) { 22 | self.dates = dates 23 | self.referenceDate = referenceDate 24 | } 25 | 26 | var daily: Int { 27 | return getStreakFor(intervalType: .Daily) 28 | } 29 | 30 | var weekly: Int { 31 | return getStreakFor(intervalType: .Weekly) 32 | } 33 | 34 | func getStreakFor(intervalType: IntervalType) -> Int { 35 | var count = 0 36 | 37 | // No events 38 | if (self.dates.count == 0) { 39 | return count 40 | } 41 | 42 | var intervalsApart: (Date, Date) -> Int? 43 | switch intervalType { 44 | case .Daily: 45 | intervalsApart = StreakCounter.daysApart 46 | case .Weekly: 47 | intervalsApart = StreakCounter.weeksApart 48 | } 49 | 50 | let datesDescending = self.dates.sorted(by: { (d1, d2) -> Bool in 51 | return d1 > d2 52 | }) 53 | 54 | let latestDate = datesDescending[0] 55 | guard let intervals = intervalsApart(latestDate, self.referenceDate) else { 56 | return count 57 | } 58 | 59 | // An event hasn't occurred in this interval or the last interval 60 | if (intervals > 1) { 61 | return count 62 | } 63 | 64 | count += 1 65 | if (dates.count == 1) { 66 | return count 67 | } 68 | 69 | var prevDate = latestDate 70 | for nextDate in datesDescending[1...(datesDescending.count - 1)] { 71 | if let intervals = intervalsApart(nextDate, prevDate), intervals <= 1 { 72 | if (intervals == 1) { 73 | count += 1 74 | } 75 | prevDate = nextDate 76 | continue 77 | } 78 | 79 | break 80 | } 81 | 82 | return count 83 | } 84 | 85 | static func daysApart(from: Date, to: Date) -> Int? { 86 | let calendar = Calendar.current 87 | let fromDate = calendar.startOfDay(for: from) 88 | let toDate = calendar.startOfDay(for: to) 89 | let dateComponents = calendar.dateComponents([.day], from: fromDate, to: toDate) 90 | guard let days = dateComponents.day else { 91 | return nil 92 | } 93 | 94 | return abs(days) 95 | } 96 | 97 | static func weeksApart(from: Date, to: Date) -> Int? { 98 | var calendar = Calendar.current 99 | // Start weeks on Mondays 100 | calendar.firstWeekday = 2 101 | let calComponents: Set = [.weekOfYear, .yearForWeekOfYear] 102 | guard let fromDate = calendar.date(from: calendar.dateComponents(calComponents, from: from)) else { 103 | return nil 104 | } 105 | guard let toDate = calendar.date(from: calendar.dateComponents(calComponents, from: to)) else { 106 | return nil 107 | } 108 | 109 | let dateComponents = calendar.dateComponents([.weekOfYear], from: fromDate, to: toDate) 110 | guard let weeks = dateComponents.weekOfYear else { 111 | return nil 112 | } 113 | 114 | return abs(weeks) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/UserDefaultsKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaultsKey.swift 3 | // FiveCalls 4 | // 5 | // Created by Ben Scheirman on 1/30/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum UserDefaultsKey : String { 12 | case hasShownWelcomeScreen 13 | 14 | case locationDisplay 15 | case locationType 16 | case locationValue 17 | 18 | case issueCompletionCache // a cached map of issue id to contact ids regarding completed calls 19 | 20 | case hasSeenFirstCallInstructions 21 | case reminderEnabled 22 | 23 | case appVersion // The current CFBundleShortVersionString 24 | case countOfCallsForRatingPrompt 25 | 26 | case lastAskedForNotificationPermission 27 | 28 | case selectIssuePath 29 | 30 | case callerID // an anoymous unique id, sometimes the old firebase userid 31 | case callingGroup // a calling group is a group that tallies their calls together 32 | } 33 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/UserLocation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserLocation.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 8/9/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreLocation 11 | import RswiftResources 12 | 13 | class UserLocation { 14 | enum LocationType: String { 15 | case address 16 | case coordinates 17 | 18 | init?(rawValue: String) { 19 | switch rawValue { 20 | //handle legacy persisted zipCodes as addresses 21 | case "address", "zipCode": 22 | self = .address 23 | case "coordinates": 24 | self = .coordinates 25 | default: 26 | return nil 27 | } 28 | } 29 | } 30 | 31 | var locationType: LocationType 32 | var locationValue: String 33 | var locationDisplay: String 34 | 35 | init(address: String, display: String? = nil) { 36 | locationType = .address 37 | locationValue = address 38 | locationDisplay = display ?? R.string.localizable.locatingTemp() 39 | } 40 | 41 | init(location: CLLocation, display: String? = nil) { 42 | locationType = .coordinates 43 | locationValue = "\(location.coordinate.latitude),\(location.coordinate.longitude)" 44 | locationDisplay = display ?? "..." 45 | } 46 | } 47 | 48 | extension UserLocation: CustomStringConvertible { 49 | var description: String { 50 | return "type: \(locationType.rawValue) value: \(locationValue) | display: \(locationDisplay)" 51 | } 52 | } 53 | 54 | extension UserLocation: Equatable { 55 | static func == (lhs: UserLocation, rhs: UserLocation) -> Bool { 56 | return lhs.locationType == rhs.locationType 57 | && lhs.locationValue == rhs.locationValue 58 | && lhs.locationDisplay == rhs.locationDisplay 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/UserStats.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserStats.swift 3 | // FiveCalls 4 | // 5 | // Created by Melville Stanley on 3/5/18. 6 | // Copyright © 2018 5calls. All rights reserved. 7 | // 8 | 9 | // We expect the JSON to look like this: 10 | // { 11 | // "contact": 221, 12 | // "voicemail": 158, 13 | // "unavailable": 32 14 | // } 15 | 16 | struct UserStats { 17 | let contact: Int? 18 | let voicemail: Int? 19 | let unavailable: Int? 20 | let weeklyStreak: Int? 21 | } 22 | 23 | extension UserStats : JSONSerializable { 24 | init?(dictionary: JSONDictionary) { 25 | let weeklyStreak = dictionary["weeklyStreak"] as? Int 26 | 27 | var contact: Int? 28 | var voicemail: Int? 29 | var unavailable: Int? 30 | if let stats = dictionary["stats"] as? JSONDictionary { 31 | contact = stats["contact"] as? Int 32 | voicemail = stats["voicemail"] as? Int 33 | unavailable = stats["unavailable"] as? Int 34 | } 35 | 36 | self.init(contact: contact, 37 | voicemail: voicemail, 38 | unavailable: unavailable, 39 | weeklyStreak: weeklyStreak) 40 | } 41 | 42 | func totalCalls() -> Int { 43 | return (contact ?? 0) + (voicemail ?? 0) + (unavailable ?? 0) 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/WebView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebView.swift 3 | // FiveCalls 4 | // 5 | // Created by Christopher Selin on 9/25/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | @preconcurrency import WebKit 11 | 12 | enum WebViewContent: String { 13 | case whycall 14 | case whoweare 15 | 16 | var navigationTitle: String { 17 | switch self { 18 | case .whycall: 19 | return R.string.localizable.aboutWebviewTitleWhyCall() 20 | case .whoweare: 21 | return R.string.localizable.aboutWebviewTitleWhoWeAre() 22 | } 23 | } 24 | } 25 | 26 | struct WebView: UIViewRepresentable { 27 | let webViewContent: WebViewContent 28 | 29 | func makeUIView(context: Context) -> WKWebView { 30 | let webView = WKWebView() 31 | webView.navigationDelegate = context.coordinator 32 | return webView 33 | } 34 | 35 | func updateUIView(_ webView: WKWebView, context: Context) { 36 | let path = Bundle.main.path(forResource: "about-\(webViewContent.rawValue)", ofType: "html")! 37 | let data = try! Data(contentsOf: URL(fileURLWithPath: path)) 38 | let htmlString = String(data: data, encoding: .utf8)! 39 | webView.loadHTMLString(htmlString, baseURL: Bundle.main.bundleURL) 40 | } 41 | 42 | class Coordinator: NSObject, WKNavigationDelegate { 43 | func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { 44 | switch navigationAction.navigationType { 45 | case .linkActivated: 46 | // Open links in Safari 47 | guard let url = navigationAction.request.url else { 48 | decisionHandler(.allow) 49 | return 50 | } 51 | UIApplication.shared.open(url) 52 | 53 | decisionHandler(.cancel) 54 | default: 55 | // Handle other navigation types... 56 | decisionHandler(.allow) 57 | } 58 | } 59 | } 60 | 61 | func makeCoordinator() -> WebView.Coordinator { 62 | Coordinator() 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/Welcome.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Welcome.swift 3 | // FiveCalls 4 | // 5 | // Created by Christopher Selin on 10/21/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct Welcome: View { 12 | @AppStorage(UserDefaultsKey.hasShownWelcomeScreen.rawValue) var hasShownWelcomeScreen = false 13 | 14 | @Environment(\.dismiss) var dismiss 15 | @EnvironmentObject var store: Store 16 | 17 | var onContinue: (() -> Void)? 18 | 19 | var subMessage: String { 20 | guard store.state.globalCallCount > 0 else { 21 | return "" 22 | } 23 | 24 | return String(format: R.string.localizable.welcomeSection3Calls( 25 | StatsViewModel(numberOfCalls: store.state.globalCallCount).formattedNumberOfCalls) 26 | ) 27 | } 28 | 29 | var subMessageOpacity: Double { 30 | store.state.globalCallCount > 0 ? 1 : 0 31 | } 32 | 33 | var body: some View { 34 | ScrollView { 35 | Grid(verticalSpacing: 30) { 36 | Image(decorative: R.image.fivecallsLogotype) 37 | .resizable() 38 | .aspectRatio(contentMode: .fit) 39 | .frame(width: 292) 40 | .padding(.vertical, 24) 41 | GridRow() { 42 | Image(systemName: "phone.badge.waveform") 43 | .symbolRenderingMode(.palette) 44 | .foregroundStyle(.red, .blue) 45 | .font(.title) 46 | .accessibilityHidden(true) 47 | VStack(alignment: .leading) { 48 | Text(R.string.localizable.welcomeSection1Title()) 49 | .fontWeight(.heavy) 50 | Text(R.string.localizable.welcomeSection1Message()) 51 | } 52 | .accessibilityElement(children: .combine) 53 | } 54 | GridRow() { 55 | Image(systemName: "goforward.5") 56 | .foregroundStyle(.blue) 57 | .font(.title) 58 | .accessibilityHidden(true) 59 | VStack(alignment: .leading) { 60 | Text(R.string.localizable.welcomeSection2Title()) 61 | .fontWeight(.heavy) 62 | Text(R.string.localizable.welcomeSection2Message()) 63 | } 64 | .accessibilityElement(children: .combine) 65 | } 66 | GridRow() { 67 | Image(systemName: "person.2") 68 | .opacity(subMessageOpacity) 69 | .symbolRenderingMode(.palette) 70 | .foregroundStyle(.blue, .red) 71 | .font(.title) 72 | .accessibilityHidden(true) 73 | Text(subMessage) 74 | .fontWeight(.heavy) 75 | .opacity(subMessageOpacity) 76 | .gridColumnAlignment(.leading) 77 | } 78 | Spacer() 79 | Spacer() 80 | Button(action: { 81 | onContinue?() 82 | dismiss() 83 | }) { 84 | Text(R.string.localizable.welcomeButtonTitle()) 85 | .foregroundColor(.white) 86 | .padding() 87 | .frame(maxWidth: .infinity) 88 | .background { 89 | RoundedRectangle(cornerRadius: 6) 90 | .foregroundColor(.blue) 91 | } 92 | } 93 | } 94 | .onAppear() { 95 | hasShownWelcomeScreen = true 96 | if store.state.globalCallCount == 0 { 97 | store.dispatch(action: .FetchStats(nil)) 98 | } 99 | } 100 | .frame(maxWidth: .infinity) 101 | .padding(30) 102 | } 103 | } 104 | } 105 | 106 | struct Welcome_Previews: PreviewProvider { 107 | static let previewState = { 108 | var state = AppState() 109 | state.globalCallCount = 12345 110 | return state 111 | }() 112 | 113 | static let previewStore = Store(state: previewState, middlewares: [appMiddleware()]) 114 | 115 | static var previews: some View { 116 | Welcome().environmentObject(previewStore) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /FiveCalls/FiveCalls/functions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // functions.swift 3 | // FiveCalls 4 | // 5 | // Created by Ben Scheirman on 2/13/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | func isUITesting() -> Bool { 12 | return ProcessInfo.processInfo.environment["UI_TESTING"] == "1" 13 | } 14 | 15 | -------------------------------------------------------------------------------- /FiveCalls/FiveCallsTests/AppStateTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import FiveCalls 3 | 4 | final class AppStateTests: XCTestCase { 5 | 6 | var sut: AppState! 7 | 8 | override func setUp() { 9 | super.setUp() 10 | sut = AppState() 11 | } 12 | 13 | override func tearDown() { 14 | sut = nil 15 | super.tearDown() 16 | } 17 | 18 | func testIssueCalledOn_WhenNoCompletions_ReturnsFalse() { 19 | let issueID = 123 20 | let contactID = "B0001234" 21 | 22 | let result = sut.issueCalledOn(issueID: issueID, contactID: contactID) 23 | 24 | XCTAssertFalse(result) 25 | } 26 | 27 | func testIssueCalledOn_WhenContactCalledForIssue_ReturnsTrue() { 28 | let issueID = 123 29 | let contactID = "B0001234" 30 | sut.issueCompletion = [issueID: ["\(contactID)-contacted"]] 31 | 32 | let result = sut.issueCalledOn(issueID: issueID, contactID: contactID) 33 | 34 | XCTAssertTrue(result) 35 | } 36 | 37 | func testIssueCalledOn_WhenDifferentContactCalledForIssue_ReturnsFalse() { 38 | let issueID = 123 39 | let contactID = "B0001234" 40 | sut.issueCompletion = [issueID: ["B0005678-contacted"]] 41 | 42 | let result = sut.issueCalledOn(issueID: issueID, contactID: contactID) 43 | 44 | XCTAssertFalse(result) 45 | } 46 | 47 | func testIssueCalledOn_WhenContactCalledForDifferentIssue_ReturnsFalse() { 48 | let issueID = 123 49 | let contactID = "B0001234" 50 | sut.issueCompletion = [456: ["\(contactID)-contacted"]] 51 | 52 | let result = sut.issueCalledOn(issueID: issueID, contactID: contactID) 53 | 54 | XCTAssertFalse(result) 55 | } 56 | 57 | func testIssueCalledOn_WithMultipleOutcomes_ReturnsTrue() { 58 | let issueID = 123 59 | let contactID = "B0001234" 60 | sut.issueCompletion = [issueID: ["B0005678-contacted", "\(contactID)-unavailable"]] 61 | 62 | let result = sut.issueCalledOn(issueID: issueID, contactID: contactID) 63 | 64 | XCTAssertTrue(result) 65 | } 66 | 67 | func testIssueCalledOn_WithHyphenatedContactID_ReturnsTrue() { 68 | let issueID = 123 69 | let contactID = "ca-newsom" 70 | sut.issueCompletion = [issueID: ["\(contactID)-contacted"]] 71 | 72 | let result = sut.issueCalledOn(issueID: issueID, contactID: contactID) 73 | 74 | XCTAssertTrue(result) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /FiveCalls/FiveCallsTests/ContactLogsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContactLogsTests.swift 3 | // FiveCalls 4 | // 5 | // Created by Ben Scheirman on 2/5/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import FiveCalls 11 | 12 | class ContactLogsTests: XCTestCase { 13 | 14 | override func setUp() { 15 | super.setUp() 16 | ContactLogs.removeData() 17 | } 18 | 19 | override func tearDown() { 20 | super.tearDown() 21 | } 22 | 23 | func testLoadsEmptyContactLogs() { 24 | let logs = ContactLogs.load() 25 | let expected: [ContactLog] = [] 26 | XCTAssertEqual(logs.all, expected) 27 | } 28 | 29 | func testSavingLog() { 30 | let dateFormatter = DateFormatter() 31 | dateFormatter.dateFormat = "yyyy-MM-dd" 32 | let date = dateFormatter.date(from: "1984-01-24")! 33 | let log = ContactLog(issueId: "issue1", contactId: "contact1", phone: "111-222-3333", outcome: "Left Voicemail", date: date, reported: true) 34 | var logsToSave = ContactLogs() 35 | logsToSave.all = [log] 36 | logsToSave.save() 37 | 38 | let loadedLogs: [ContactLog] = ContactLogs.load().all 39 | XCTAssertEqual([log], loadedLogs) 40 | 41 | var logs = ContactLogs() 42 | logs.add(log: log) 43 | 44 | let newlyLoadedLogs = ContactLogs.load() 45 | XCTAssertEqual(newlyLoadedLogs.all, [log]) 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /FiveCalls/FiveCallsTests/ContactParsingTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContactParsingTest.swift 3 | // FiveCallsTests 4 | // 5 | // Created by Nick O'Neill on 12/20/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import FiveCalls 11 | 12 | final class ContactParsingTest: XCTestCase { 13 | 14 | override func setUpWithError() throws { 15 | // Put setup code here. This method is called before the invocation of each test method in the class. 16 | UserDefaults.standard.set(Bundle(for: ContactParsingTest.self).path(forResource: "GET-v1-reps", ofType: "json"), forKey: "mock-GET:/v1/reps") 17 | } 18 | 19 | override func tearDownWithError() throws { 20 | // Put teardown code here. This method is called after the invocation of each test method in the class. 21 | } 22 | 23 | func testParseContacts() throws { 24 | let exp = expectation(description: "parsing contacts") 25 | let config = URLSessionConfiguration.ephemeral 26 | config.protocolClasses = [ProtocolMock.self] 27 | let fetchContacts = FetchContactsOperation(location: UserLocation(address: "3400 24th St, SF, CA"), config: config) 28 | fetchContacts.completionBlock = { 29 | if let error = fetchContacts.error { 30 | return XCTFail("contact request failed: \(error)") 31 | } 32 | guard let contacts = fetchContacts.contacts else { return XCTFail("no contacts present") } 33 | let contactCountExpected = 8 34 | XCTAssert(contacts.count == contactCountExpected, "found \(contacts.count) issues, expected \(contactCountExpected)") 35 | let contactNameExpected = "Gavin Newsom" 36 | XCTAssert(contacts[0].name == contactNameExpected, "first contact was \(contacts[0].name) not \(contactNameExpected)") 37 | let fieldOfficeExpected = "Fresno" 38 | XCTAssert(contacts[3].fieldOffices[0].city == fieldOfficeExpected, "field office was \(contacts[3].fieldOffices[0].city), not \(fieldOfficeExpected)") 39 | exp.fulfill() 40 | } 41 | OperationQueue.main.addOperation(fetchContacts) 42 | // TODO: as part of an operations refactor, await this return so this test finishes ~immediately 43 | waitForExpectations(timeout: 2) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /FiveCalls/FiveCallsTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 71 21 | 22 | 23 | -------------------------------------------------------------------------------- /FiveCalls/FiveCallsTests/IssueParsingTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IssueParsingTest.swift 3 | // FiveCallsTests 4 | // 5 | // Created by Nick O'Neill on 12/19/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import FiveCalls 11 | 12 | final class IssueParsingTest: XCTestCase { 13 | override func setUpWithError() throws { 14 | UserDefaults.standard.set(Bundle(for: IssueParsingTest.self).path(forResource: "GET-v1-issues", ofType: "json"), forKey: "mock-GET:/v1/issues") 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testParseIssues() throws { 22 | let exp = expectation(description: "parsing issues") 23 | let config = URLSessionConfiguration.ephemeral 24 | config.protocolClasses = [ProtocolMock.self] 25 | let fetchIssues = FetchIssuesOperation(config: config) 26 | fetchIssues.completionBlock = { 27 | guard let issues = fetchIssues.issuesList else { return XCTFail("no issues present") } 28 | let issueCountExpected = 60 29 | XCTAssert(issues.count == issueCountExpected, "found \(issues.count) issues, expected \(issueCountExpected)") 30 | let issueIDExpected = 664 31 | XCTAssert(issues[0].id == issueIDExpected, "first issue was not id \(issueIDExpected) as expected") 32 | let issueCreatedAtExpected: Double = 1565582054 33 | XCTAssert(issues[11].createdAt.timeIntervalSince1970 == issueCreatedAtExpected, "12th issue did not have created date as expected") 34 | exp.fulfill() 35 | } 36 | OperationQueue.main.addOperation(fetchIssues) 37 | // TODO: as part of an operations refactor, await this return so this test finishes ~immediately 38 | waitForExpectations(timeout: 2) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /FiveCalls/FiveCallsTests/IssueTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IssueTests.swift 3 | // FiveCallsTests 4 | // 5 | // Created by Nick O'Neill on 1/4/24. 6 | // Copyright © 2024 5calls. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import FiveCalls 11 | 12 | final class IssueTests: XCTestCase { 13 | 14 | override func setUpWithError() throws { 15 | // Put setup code here. This method is called before the invocation of each test method in the class. 16 | } 17 | 18 | override func tearDownWithError() throws { 19 | // Put teardown code here. This method is called after the invocation of each test method in the class. 20 | } 21 | 22 | func testValidIssue() throws { 23 | // intentionally misformatting a slug value here 24 | let issue = Issue(id: 813, meta: "", name: "Support the Act", slug: "support-act-slug ", reason: Issue.issueReason, script: Issue.issueScript, categories: [Category(name: "Budget")], active: true, outcomeModels: [Outcome(label: "Contacted", status: "contact"), Outcome(label: "Voicemail", status: "voicemail")], contactType: "reps", contactAreas: ["US House", "US Senate"], createdAt: Date(timeIntervalSince1970: 1688015904)) 25 | 26 | let expectedShareURL = URL(string: "https://5calls.org/issue/support-act-slug/")! 27 | let expectedShareImageURL = URL(string: "https://api.5calls.org/v1/issue/813/share/t")! 28 | XCTAssert(issue.shareURL == expectedShareURL, "share url was \(issue.shareURL), expected \(expectedShareURL)") 29 | XCTAssert(issue.shareImageURL == expectedShareImageURL, "share image url was \(issue.shareImageURL), expected \(expectedShareImageURL)") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /FiveCalls/FiveCallsTests/LocationSheetTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationSheetTests.swift 3 | // FiveCallsTests 4 | // 5 | // Created by Christopher Selin on 1/18/24. 6 | // Copyright © 2024 5calls. All rights reserved. 7 | // 8 | 9 | import CoreLocation 10 | @testable import FiveCalls 11 | import XCTest 12 | 13 | private let kLocation = CLLocation(latitude: 37.752193, longitude: -122.420668) 14 | 15 | @MainActor 16 | final class LocationSheetTests: XCTestCase { 17 | 18 | func testGetLocationInfo() async throws { 19 | let locationSheet = LocationSheet() 20 | let locationInfo = try await locationSheet.getLocationInfo(from: kLocation) 21 | XCTAssertEqual(locationInfo["longitude"] as! CLLocationDegrees, kLocation.coordinate.longitude) 22 | XCTAssertEqual(locationInfo["latitude"] as! CLLocationDegrees, kLocation.coordinate.latitude) 23 | XCTAssertEqual(locationInfo["displayName"] as! String, "San Francisco") 24 | XCTAssertEqual(locationInfo["zipcode"] as! String, "94110") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /FiveCalls/FiveCallsTests/ReportParsingTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReportParsingTest.swift 3 | // FiveCallsTests 4 | // 5 | // Created by Nick O'Neill on 12/20/23. 6 | // Copyright © 2023 5calls. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import FiveCalls 11 | 12 | final class ReportParsingTest: XCTestCase { 13 | override func setUpWithError() throws { 14 | UserDefaults.standard.set(Bundle(for: ContactParsingTest.self).path(forResource: "GET-v1-report", ofType: "json"), forKey: "mock-GET:/v1/report") 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testParsingReport() throws { 22 | let exp = expectation(description: "parsing report") 23 | let config = URLSessionConfiguration.ephemeral 24 | config.protocolClasses = [ProtocolMock.self] 25 | 26 | let getReport = FetchStatsOperation(config: config) 27 | getReport.completionBlock = { 28 | let totalCallExpected = 3110741 29 | XCTAssert(getReport.numberOfCalls == totalCallExpected, "number of calls was \(getReport.numberOfCalls ?? 0), expected \(3110741)") 30 | let issueCallExpected = 98673 31 | XCTAssert(getReport.numberOfIssueCalls == issueCallExpected, "number of issue calls was \(getReport.numberOfIssueCalls ?? 0), expected \(issueCallExpected)") 32 | let donateOnExpected = true 33 | XCTAssert(getReport.donateOn == donateOnExpected, "expected donateOn to be \(donateOnExpected)") 34 | exp.fulfill() 35 | } 36 | OperationQueue.main.addOperation(getReport) 37 | // TODO: as part of an operations refactor, await this return so this test finishes ~immediately 38 | waitForExpectations(timeout: 2) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /FiveCalls/FiveCallsTests/UserLocationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserLocationTests.swift 3 | // FiveCallsTests 4 | // 5 | // Created by Christopher Selin on 1/18/24. 6 | // Copyright © 2024 5calls. All rights reserved. 7 | // 8 | 9 | import CoreLocation 10 | @testable import FiveCalls 11 | import XCTest 12 | 13 | final class UserLocationTests: XCTestCase { 14 | func testLocationTypeInitialization() { 15 | // Test case for "address" raw value 16 | if let locationType = UserLocation.LocationType(rawValue: "address") { 17 | XCTAssertEqual(locationType, .address) 18 | } else { 19 | XCTFail("Failed to initialize LocationType with raw value 'address'") 20 | } 21 | 22 | // Test case for "zipCode" raw value 23 | if let locationType = UserLocation.LocationType(rawValue: "zipCode") { 24 | XCTAssertEqual(locationType, .address) 25 | } else { 26 | XCTFail("Failed to initialize LocationType with raw value 'zipCode'") 27 | } 28 | 29 | // Test case for "coordinates" raw value 30 | if let locationType = UserLocation.LocationType(rawValue: "coordinates") { 31 | XCTAssertEqual(locationType, .coordinates) 32 | } else { 33 | XCTFail("Failed to initialize LocationType with raw value 'coordinates'") 34 | } 35 | 36 | // Test case for unknown raw value 37 | let unknownRawValue = "unknown" 38 | XCTAssertNil(UserLocation.LocationType(rawValue: unknownRawValue), "Expected nil for unknown raw value '\(unknownRawValue)'") 39 | } 40 | 41 | func testAddressLocationNoDisplay() throws { 42 | let userLocation = UserLocation(address: "123 Main St") 43 | XCTAssertEqual(userLocation.locationType, .address) 44 | XCTAssertEqual(userLocation.locationValue, "123 Main St") 45 | XCTAssertEqual(userLocation.locationDisplay, R.string.localizable.locatingTemp()) 46 | } 47 | 48 | func testAddressLocationWithDisplay() throws { 49 | let userLocation = UserLocation(address: "123 Main St", display: "123 Main St") 50 | XCTAssertEqual(userLocation.locationType, .address) 51 | XCTAssertEqual(userLocation.locationValue, "123 Main St") 52 | XCTAssertEqual(userLocation.locationDisplay, "123 Main St") 53 | } 54 | 55 | func testCoordinatesLocationNoDisplay() throws { 56 | let userLocation = UserLocation(location: CLLocation(latitude: 37.33233141, longitude: -122.0312186)) 57 | XCTAssertEqual(userLocation.locationType, .coordinates) 58 | XCTAssertEqual(userLocation.locationValue, "37.33233141,-122.0312186") 59 | XCTAssertEqual(userLocation.locationDisplay, "...") 60 | } 61 | 62 | func testCoordinatesLocationWithDisplay() throws { 63 | let userLocation = UserLocation(location: CLLocation(latitude: 37.33233141, longitude: -122.0312186), display: "Cupertino, CA") 64 | XCTAssertEqual(userLocation.locationType, .coordinates) 65 | XCTAssertEqual(userLocation.locationValue, "37.33233141,-122.0312186") 66 | XCTAssertEqual(userLocation.locationDisplay, "Cupertino, CA") 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /FiveCalls/FiveCallsUITests/FiveCallsUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FiveCallsUITests.swift 3 | // FiveCallsUITests 4 | // 5 | // Created by Ben Scheirman on 2/8/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import FiveCalls 11 | 12 | class FiveCallsUITests: XCTestCase { 13 | 14 | var app: XCUIApplication! 15 | 16 | @MainActor override func setUp() { 17 | super.setUp() 18 | 19 | continueAfterFailure = false 20 | app = XCUIApplication() 21 | app.launchEnvironment = ["UI_TESTING" : "1"] 22 | loadJSONFixtures(application: app) 23 | setupSnapshot(app) 24 | app.launch() 25 | } 26 | 27 | override func tearDown() { 28 | super.tearDown() 29 | } 30 | 31 | @MainActor func testTakeScreenshots() { 32 | app.buttons["Get Started"].tap() 33 | 34 | // set location 35 | app.buttons["Set your location"].tap() 36 | app.textFields["locationField"].tap() 37 | app.textFields["locationField"].typeText("94110") 38 | app.textFields["locationField"].typeText("\r") 39 | 40 | // select first issue 41 | app.collectionViews.cells.element(boundBy: 0).tap() 42 | snapshot("1-issue-detail") 43 | 44 | app.swipeUp() 45 | app.buttons["See your script"].tap() 46 | snapshot("2-script") 47 | 48 | app.swipeUp() 49 | // nav to second contact that has more phone numbers 50 | app.buttons["Contact"].tap() 51 | // open the more phones menu 52 | app.buttons["localNumbers"].tap() 53 | 54 | snapshot("3-local-numbers") 55 | } 56 | 57 | private func loadJSONFixtures(application: XCUIApplication) { 58 | let bundle = Bundle(for: FiveCallsUITests.self) 59 | application.launchEnvironment["GET:/v1/reps"] = bundle.path(forResource: "GET-v1-reps-UI", ofType: "json") 60 | application.launchEnvironment["GET:/v1/issues"] = bundle.path(forResource: "GET-v1-issues-UI", ofType: "json") 61 | application.launchEnvironment["GET:/v1/report"] = bundle.path(forResource: "GET-v1-report", ofType: "json") 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /FiveCalls/FiveCallsUITests/GET-v1-issues-UI.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 664, 4 | "createdAt": "2019-12-18T17:56:35-08:00", 5 | "group": { 6 | "id": 0, 7 | "createdAt": "0001-01-01T00:00:00Z", 8 | "name": "", 9 | "groupID": "", 10 | "subtitle": "", 11 | "description": "", 12 | "photoURL": "", 13 | "totalCalls": 0, 14 | "customCalls": false, 15 | "subscribed": false 16 | }, 17 | "name": "Support statehood for Washington D.C.", 18 | "reason": "Residents of Washington, D.C. pay federal taxes, but don’t have Senators or a Representative to vote on issues affecting them. Local laws that voters support can be overruled by Congress without the protective rights of a state. D.C. has a larger population than both Vermont and Wyoming. In 2016, 86% of Washington, D.C. voted for statehood over the unjust status quo.\n\nLawmakers should prioritize making the District of Columbia a state and ensure that every American has fair and equal representation in the federal government. Demand that your Members of Congress support H.R. 51 and S. 51 to finally grant statehood to the District of Columbia.", 19 | "script": "Hi, my name is **[NAME]** and I'm a constituent from [CITY, ZIP].\n\nI'm calling to urge [SEN/REP NAME] to support H.R. 51: The Washington, D.C. Admission Act. Over 690,000 residents of the District of Columbia deserve self-determination and full voting representation in Congress. Fulfill the wishes of the people of DC, who voted 86% in favor of statehood.\n\nThank you for your time and attention.\n\n**IF LEAVING A VOICEMAIL:** Please leave your full street address to ensure your call is tallied.", 20 | "categories": [], 21 | "contactType": "REPS", 22 | "contacts": null, 23 | "contactAreas": [ 24 | "US Senate", 25 | "US House" 26 | ], 27 | "outcomeModels": [ 28 | { 29 | "label": "unavailable", 30 | "status": "unavailable" 31 | }, 32 | { 33 | "label": "voicemail", 34 | "status": "voicemail" 35 | }, 36 | { 37 | "label": "contact", 38 | "status": "contact" 39 | }, 40 | { 41 | "label": "skip", 42 | "status": "skip" 43 | } 44 | ], 45 | "stats": { 46 | "calls": 435 47 | }, 48 | "slug": "trump-impeachment-senate-trial", 49 | "active": true, 50 | "meta": "recWUAJFR3V99JNjR" 51 | }, 52 | { 53 | "id": 573, 54 | "createdAt": "2019-05-06T20:15:01-07:00", 55 | "group": { 56 | "id": 0, 57 | "createdAt": "0001-01-01T00:00:00Z", 58 | "name": "", 59 | "groupID": "", 60 | "subtitle": "", 61 | "description": "", 62 | "photoURL": "", 63 | "totalCalls": 0, 64 | "customCalls": false, 65 | "subscribed": false 66 | }, 67 | "name": "Second screenshot issue", 68 | "reason": "another reason", 69 | "script": "Hi, my name is **[NAME]** and I'm a constituent from [CITY, ZIP].\n\nI'm calling to urge [REP/SEN NAME] to support **[HR 986 if House, S 466 if SENATE],** the Protecting Americans with Pre-existing Conditions Act. Permitting the use of ACA subsidies to purchase short-term insurance plans puts consumers at risk for medical bankruptcy and weakens protections for the thousands of people with preexisting health conditions. \n\nThank you for your time and attention.\n\n**IF LEAVING A VOICEMAIL:** Please leave your full street address to ensure your call is tallied.", 70 | "categories": [ 71 | { 72 | "name": "Healthcare" 73 | } 74 | ], 75 | "contactType": "REPS", 76 | "contacts": null, 77 | "contactAreas": [ 78 | "US Senate", 79 | "US House" 80 | ], 81 | "outcomeModels": [ 82 | { 83 | "label": "unavailable", 84 | "status": "unavailable" 85 | }, 86 | { 87 | "label": "voicemail", 88 | "status": "voicemail" 89 | }, 90 | { 91 | "label": "contact", 92 | "status": "contact" 93 | }, 94 | { 95 | "label": "skip", 96 | "status": "skip" 97 | } 98 | ], 99 | "stats": { 100 | "calls": 524 101 | }, 102 | "slug": "protecting-americans-preexisting-conditions-act", 103 | "active": true, 104 | "meta": "recs0Er3p4wNjHrAu" 105 | } 106 | ] 107 | -------------------------------------------------------------------------------- /FiveCalls/FiveCallsUITests/GET-v1-report.json: -------------------------------------------------------------------------------- 1 | { 2 | "count": 3110741, 3 | "issueCount": 98673, 4 | "donateOn": true 5 | } 6 | -------------------------------------------------------------------------------- /FiveCalls/FiveCallsUITests/GET-v1-reps-UI.json: -------------------------------------------------------------------------------- 1 | { 2 | "location": "San Francisco", 3 | "lowAccuracy": false, 4 | "state": "CA", 5 | "district": "17th", 6 | "representatives": [ 7 | { 8 | "id": "CA-GavinNewsom", 9 | "name": "Gavin Newsom", 10 | "phone": "916-445-2841", 11 | "photoURL": "https://repimages.5calls.org/us-governors/512x512/gavin-newsom.jpg", 12 | "ocdID": "", 13 | "party": "democrat", 14 | "state": "CA", 15 | "reason": "This is your State Governor.", 16 | "area": "Governor", 17 | "field_offices": [] 18 | }, 19 | { 20 | "id": "CA-NancyPelosi", 21 | "name": "Nancy Pelosi", 22 | "phone": "202-225-4965", 23 | "photoURL": "https://images.5calls.org/house/256/P000197.jpg", 24 | "ocdID": "", 25 | "party": "democrat", 26 | "state": "CA", 27 | "reason": "This is your representative in the House.", 28 | "area": "US House", 29 | "field_offices": [ 30 | { 31 | "phone": "415-556-4862", 32 | "city": "" 33 | } 34 | ] 35 | }, 36 | { 37 | "id": "CA-LaphonzaButler", 38 | "name": "Laphonza Butler", 39 | "phone": "202-224-3841", 40 | "photoURL": "https://images.5calls.org/senate/256/B001320.jpg", 41 | "ocdID": "", 42 | "party": "democrat", 43 | "state": "CA", 44 | "reason": "This is one of your two Senators.", 45 | "area": "US Senate", 46 | "field_offices": [ 47 | { 48 | "phone": "559-485-7430", 49 | "city": "Fresno" 50 | }, 51 | { 52 | "phone": "310-914-7300", 53 | "city": "Los Angeles" 54 | }, 55 | { 56 | "phone": "619-231-9712", 57 | "city": "San Diego" 58 | }, 59 | { 60 | "phone": "415-393-0707", 61 | "city": "San Francisco" 62 | } 63 | ] 64 | }, 65 | { 66 | "id": "CA-AlexPadilla", 67 | "name": "Alex Padilla", 68 | "phone": "202-224-3553", 69 | "photoURL": "https://images.5calls.org/senate/256/P000145.jpg", 70 | "ocdID": "", 71 | "party": "democrat", 72 | "state": "CA", 73 | "reason": "This is one of your two Senators.", 74 | "area": "US Senate", 75 | "field_offices": [ 76 | { 77 | "phone": "916-448-2787", 78 | "city": "Fresno" 79 | }, 80 | { 81 | "phone": "213-894-5000", 82 | "city": "Los Angeles" 83 | }, 84 | { 85 | "phone": "916-448-2787", 86 | "city": "Sacramento" 87 | }, 88 | { 89 | "phone": "619-239-3884", 90 | "city": "San Diego" 91 | }, 92 | { 93 | "phone": "415-355-9041", 94 | "city": "San Francisco" 95 | } 96 | ] 97 | }, 98 | { 99 | "id": "CA-DavidChiu", 100 | "name": "David Chiu", 101 | "phone": "415-557-3013", 102 | "photoURL": "https://www.assembly.ca.gov/sites/assembly.ca.gov/files/memberphotos/AD17_Chiu_Portrait150_20141201.jpg", 103 | "ocdID": "", 104 | "party": "democrat", 105 | "state": "CA", 106 | "reason": "This is one of your State Legislators.", 107 | "area": "StateLower", 108 | "field_offices": [] 109 | }, 110 | { 111 | "id": "CA-ScottD.Wiener", 112 | "name": "Scott D. Wiener", 113 | "phone": "415-557-1300", 114 | "photoURL": "https://www.senate.ca.gov/sites/senate.ca.gov/files/senator_photos/wiener.jpg", 115 | "ocdID": "", 116 | "party": "democrat", 117 | "state": "CA", 118 | "reason": "This is one of your State Legislators.", 119 | "area": "StateUpper", 120 | "field_offices": [] 121 | }, 122 | { 123 | "id": "CA-ag", 124 | "name": "Xavier Becerra", 125 | "phone": "916-445-9555", 126 | "photoURL": "https://www.naag.org/assets/redesign/images/AGs/ag-CA-Becerra-2.jpg", 127 | "ocdID": "", 128 | "party": "", 129 | "state": "CA", 130 | "reason": "This is your State Attorney General.", 131 | "area": "AttorneyGeneral", 132 | "field_offices": [] 133 | }, 134 | { 135 | "id": "CA-secstate", 136 | "name": "Alex Padilla", 137 | "phone": "916-653-7244", 138 | "photoURL": "https://www.nass.org/sites/default/files/styles/secretary/public/2017-08/alex-padilla-opt.jpg", 139 | "ocdID": "", 140 | "party": "", 141 | "state": "CA", 142 | "reason": "This is your local Secretary of State.", 143 | "area": "SecretaryOfState", 144 | "field_offices": [] 145 | } 146 | ] 147 | } 148 | -------------------------------------------------------------------------------- /FiveCalls/FiveCallsUITests/GET-v1-reps.json: -------------------------------------------------------------------------------- 1 | { 2 | "location": "San Francisco", 3 | "isSplit": false, 4 | "state": "CA", 5 | "district": "17th", 6 | "representatives": [ 7 | { 8 | "id": "CA-GavinNewsom", 9 | "name": "Gavin Newsom", 10 | "phone": "916-445-2841", 11 | "photoURL": "https://repimages.5calls.org/us-governors/512x512/gavin-newsom.jpg", 12 | "ocdID": "", 13 | "party": "democrat", 14 | "state": "CA", 15 | "reason": "This is your State Governor.", 16 | "area": "Governor", 17 | "field_offices": [] 18 | }, 19 | { 20 | "id": "CA-NancyPelosi", 21 | "name": "Nancy Pelosi", 22 | "phone": "202-225-4965", 23 | "photoURL": "https://images.5calls.org/house/256/P000197.jpg", 24 | "ocdID": "", 25 | "party": "democrat", 26 | "state": "CA", 27 | "reason": "This is your representative in the House.", 28 | "area": "US House", 29 | "field_offices": [ 30 | { 31 | "phone": "415-556-4862", 32 | "city": "" 33 | } 34 | ] 35 | }, 36 | { 37 | "id": "CA-DianneFeinstein", 38 | "name": "Dianne Feinstein", 39 | "phone": "202-224-3841", 40 | "photoURL": "https://repimages.5calls.org/us-senate/512x512/dianne-feinstein.jpg", 41 | "ocdID": "", 42 | "party": "democrat", 43 | "state": "CA", 44 | "reason": "This is one of your two Senators.", 45 | "area": "US Senate", 46 | "field_offices": [ 47 | { 48 | "phone": "559-485-7430", 49 | "city": "Fresno" 50 | }, 51 | { 52 | "phone": "310-914-7300", 53 | "city": "Los Angeles" 54 | }, 55 | { 56 | "phone": "619-231-9712", 57 | "city": "San Diego" 58 | }, 59 | { 60 | "phone": "415-393-0707", 61 | "city": "San Francisco" 62 | } 63 | ] 64 | }, 65 | { 66 | "id": "CA-KamalaHarris", 67 | "name": "Kamala Harris", 68 | "phone": "202-224-3553", 69 | "photoURL": "https://repimages.5calls.org/us-senate/512x512/kamala-harris.jpg", 70 | "ocdID": "", 71 | "party": "democrat", 72 | "state": "CA", 73 | "reason": "This is one of your two Senators.", 74 | "area": "US Senate", 75 | "field_offices": [ 76 | { 77 | "phone": "916-448-2787", 78 | "city": "Fresno" 79 | }, 80 | { 81 | "phone": "213-894-5000", 82 | "city": "Los Angeles" 83 | }, 84 | { 85 | "phone": "916-448-2787", 86 | "city": "Sacramento" 87 | }, 88 | { 89 | "phone": "619-239-3884", 90 | "city": "San Diego" 91 | }, 92 | { 93 | "phone": "415-355-9041", 94 | "city": "San Francisco" 95 | } 96 | ] 97 | }, 98 | { 99 | "id": "CA-DavidChiu", 100 | "name": "David Chiu", 101 | "phone": "415-557-3013", 102 | "photoURL": "https://www.assembly.ca.gov/sites/assembly.ca.gov/files/memberphotos/AD17_Chiu_Portrait150_20141201.jpg", 103 | "ocdID": "", 104 | "party": "democrat", 105 | "state": "CA", 106 | "reason": "This is one of your State Legislators.", 107 | "area": "StateLower", 108 | "field_offices": [] 109 | }, 110 | { 111 | "id": "CA-ScottD.Wiener", 112 | "name": "Scott D. Wiener", 113 | "phone": "415-557-1300", 114 | "photoURL": "https://www.senate.ca.gov/sites/senate.ca.gov/files/senator_photos/wiener.jpg", 115 | "ocdID": "", 116 | "party": "democrat", 117 | "state": "CA", 118 | "reason": "This is one of your State Legislators.", 119 | "area": "StateUpper", 120 | "field_offices": [] 121 | }, 122 | { 123 | "id": "CA-ag", 124 | "name": "Xavier Becerra", 125 | "phone": "916-445-9555", 126 | "photoURL": "https://www.naag.org/assets/redesign/images/AGs/ag-CA-Becerra-2.jpg", 127 | "ocdID": "", 128 | "party": "", 129 | "state": "CA", 130 | "reason": "This is your State Attorney General.", 131 | "area": "AttorneyGeneral", 132 | "field_offices": [] 133 | }, 134 | { 135 | "id": "CA-secstate", 136 | "name": "Alex Padilla", 137 | "phone": "916-653-7244", 138 | "photoURL": "https://www.nass.org/sites/default/files/styles/secretary/public/2017-08/alex-padilla-opt.jpg", 139 | "ocdID": "", 140 | "party": "", 141 | "state": "CA", 142 | "reason": "This is your local Secretary of State.", 143 | "area": "SecretaryOfState", 144 | "field_offices": [] 145 | } 146 | ] 147 | } 148 | -------------------------------------------------------------------------------- /FiveCalls/FiveCallsUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 71 21 | 22 | 23 | -------------------------------------------------------------------------------- /FiveCalls/FiveCallsUITests/POST-report.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /FiveCalls/NotificationsService/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | NotificationsService 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | XPC! 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | 71 23 | NSExtension 24 | 25 | NSExtensionPointIdentifier 26 | com.apple.usernotifications.service 27 | NSExtensionPrincipalClass 28 | $(PRODUCT_MODULE_NAME).NotificationService 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /FiveCalls/NotificationsService/NotificationService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationsService.swift 3 | // FiveCalls 4 | // 5 | // Created by Nick O'Neill on 10/16/17. 6 | // Copyright © 2017 5calls. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UserNotifications 11 | import OneSignalExtension 12 | 13 | class NotificationService: UNNotificationServiceExtension { 14 | 15 | var contentHandler: ((UNNotificationContent) -> Void)? 16 | var receivedRequest: UNNotificationRequest! 17 | var bestAttemptContent: UNMutableNotificationContent? 18 | 19 | override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { 20 | self.receivedRequest = request; 21 | self.contentHandler = contentHandler 22 | bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) 23 | 24 | if let bestAttemptContent = bestAttemptContent { 25 | OneSignalExtension.didReceiveNotificationExtensionRequest(self.receivedRequest, with: bestAttemptContent, withContentHandler: contentHandler) 26 | } 27 | } 28 | 29 | override func serviceExtensionTimeWillExpire() { 30 | // Called just before the extension will be terminated by the system. 31 | // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. 32 | if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { 33 | OneSignalExtension.serviceExtensionTimeWillExpireRequest(self.receivedRequest, with: self.bestAttemptContent) 34 | contentHandler(bestAttemptContent) 35 | } 36 | } 37 | 38 | } 39 | 40 | -------------------------------------------------------------------------------- /FiveCalls/NotificationsService/NotificationsService.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source "https://rubygems.org" 3 | 4 | gem "dotenv" 5 | gem "fastlane" 6 | gem "badge" 7 | gem "xcov" 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 5calls 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 5Calls iOS App 2 | 3 | This is the repository for the iOS app for [5Calls.org](https://5calls.org). 4 | 5 | [![Build Status](https://app.bitrise.io/app/d786d837d94f6410/status.svg?token=BTL78uVY_9iE4XCx-iTekQ&branch=main)](https://app.bitrise.io/app/d786d837d94f6410) 6 | 7 | ## Requirements 8 | 9 | - Xcode 16 10 | - iOS 16 11 | 12 | ## Getting Started 13 | 14 | Install the dependencies: 15 | 16 | ``` 17 | bundle install 18 | ``` 19 | 20 | ## Using R.swift 21 | 22 | R.swift removes the need to use "stringly typed" resources. Instead, you can reference your app's resources Android-style, which is strongly typed. Benefits are less casting, compile time checking for resources, and a little less code. [See examples for each type here.](https://github.com/mac-cain13/R.swift/blob/master/Documentation/Examples.md) 23 | 24 | **Note**: Since 5Calls uses prototype cells instead of cell nibs, this is all you need to dequeue a cell: 25 | 26 | ``` 27 | let cell = tableView.dequeueReusableCell(withIdentifier: R.reuseIdentifier.setLocationCell, for: indexPath)! 28 | ``` 29 | 30 | You may need to put the R.swift binary from the latest release (https://github.com/mac-cain13/R.swift/releases) into `vendor/rswift` if you're getting started with this project for the first time. 31 | 32 | ## Testflight Builds 33 | 34 | > _This currently has to be done by Ben_ 35 | 36 | Install the dependencies: 37 | 38 | ``` 39 | bundle install 40 | ``` 41 | 42 | Make sure you have a `.env` file with the following keys defined: 43 | 44 | - `APPLE_ID` 45 | - `TEAM_ID` 46 | - `ITUNES_CONNECT_TEAM_ID` 47 | - `FASTLANE_APPLE_APP_SPECIFIC_PASSWORD` 48 | 49 | Update the build number manually (for now). 50 | 51 | Then run: 52 | 53 | ``` 54 | fastlane beta 55 | ``` 56 | 57 | ## License 58 | 59 | This project is released open source under the MIT License. See [LICENSE](https://raw.githubusercontent.com/5calls/ios/master/LICENSE) for more details. 60 | 61 | ## Contributors 62 | 63 | See the complete list of contributors here: https://github.com/5calls/ios/graphs/contributors 64 | -------------------------------------------------------------------------------- /fastlane/Appfile: -------------------------------------------------------------------------------- 1 | require 'dotenv' 2 | 3 | env_path = File.expand_path('../../.env', __FILE__) 4 | if File.exists?(env_path) 5 | Dotenv.load(env_path) 6 | else 7 | if !is_ci 8 | puts "ENV PATH: #{env_path}" 9 | puts "This project uses Dotenv to manage secret environment variables. Add these to your .env file." 10 | exit 1 11 | end 12 | end 13 | 14 | app_identifier "org.fivecalls.FiveCalls-ios" # The bundle identifier of your app 15 | apple_id ENV.fetch('APPLE_ID') 16 | team_id ENV.fetch('TEAM_ID') 17 | itc_team_id ENV.fetch('ITUNES_CONNECT_TEAM_ID') 18 | 19 | # you can even provide different app identifiers, Apple IDs and team names per lane: 20 | # More information: https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Appfile.md 21 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | 2 | # Customise this file, documentation can be found here: 3 | # https://github.com/fastlane/fastlane/tree/master/fastlane/docs 4 | # All available actions: https://docs.fastlane.tools/actions 5 | # can also be listed using the `fastlane actions` command 6 | 7 | # update_fastlane 8 | 9 | default_platform :ios 10 | 11 | xcodeproj = 'FiveCalls/FiveCalls.xcodeproj' 12 | scheme = 'FiveCalls' 13 | 14 | platform :ios do 15 | before_all do 16 | before_all do 17 | setup_circle_ci 18 | end 19 | end 20 | 21 | desc "Runs all the tests" 22 | lane :test do 23 | scan(project: xcodeproj, scheme: scheme, xcargs: "-skipPackagePluginValidation") 24 | end 25 | 26 | desc "Runs code coverage (requires running tests first)" 27 | lane :coverage do 28 | xcov(project: "FiveCalls/FiveCalls.xcodeproj", 29 | scheme: "FiveCalls", 30 | minimum_coverage_percentage: 25.0) 31 | end 32 | 33 | desc "Increments build number" 34 | lane :increment_build do 35 | increment_build_number(xcodeproj: xcodeproj) 36 | end 37 | 38 | lane :app_version do 39 | get_app_version(xcodeproj: xcodeproj) 40 | end 41 | 42 | def get_app_version(xcodeproj:) 43 | marketing_version = get_version_number(xcodeproj: xcodeproj) 44 | build_number = get_build_number(xcodeproj: xcodeproj) 45 | "#{marketing_version}.#{build_number}" 46 | end 47 | 48 | desc "Submit a new Beta Build to Apple TestFlight" 49 | desc "This will also make sure the profile is up to date" 50 | lane :beta do 51 | ensure_git_status_clean 52 | increment_build 53 | commit_version_bump(xcodeproj: xcodeproj) 54 | 55 | gym(project: xcodeproj, scheme: scheme) 56 | changelog = prompt_for_release_notes 57 | pilot(changelog: changelog) 58 | git_commit(path: 'fastlane/changelog.txt', message: 'Updated changelog.txt') 59 | 60 | reset_git_repo(files: app_icon_files) 61 | add_git_tag 62 | push_git_tags 63 | end 64 | 65 | desc "Deploy a new version to the App Store" 66 | lane :release do 67 | ensure_git_status_clean 68 | increment_build 69 | commit_version_bump 70 | 71 | gym(project: xcodeproj, 72 | scheme: scheme, 73 | export_xcargs: "-allowProvisioningUpdates") 74 | deliver(force: true) 75 | 76 | add_git_tag 77 | push_git_tags 78 | end 79 | 80 | # You can define as many lanes as you want 81 | 82 | after_all do |lane| 83 | # This block is called, only if the executed lane was successful 84 | 85 | # slack( 86 | # message: "Successfully deployed new App Update." 87 | # ) 88 | end 89 | 90 | error do |lane, exception| 91 | # slack( 92 | # message: exception.message, 93 | # success: false 94 | # ) 95 | end 96 | 97 | lane :snapshots do 98 | snapshot 99 | end 100 | end 101 | 102 | def app_icon_files 103 | Dir['../FiveCalls/FiveCalls/Assets.xcassets/AppIcon.appiconset/*.png'] 104 | end 105 | 106 | def prompt_for_release_notes 107 | puts "Opening changelog.txt. Edit it and quit the editor to continue." 108 | `open changelog.txt -W` 109 | File.read('changelog.txt') 110 | end 111 | 112 | 113 | # More information about multiple platforms in fastlane: https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Platforms.md 114 | # All available actions: https://docs.fastlane.tools/actions 115 | 116 | # fastlane reports which actions are used 117 | # No personal data is recorded. Learn more at https://github.com/fastlane/enhancer 118 | -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ---- 3 | 4 | # Installation 5 | 6 | Make sure you have the latest version of the Xcode command line tools installed: 7 | 8 | ```sh 9 | xcode-select --install 10 | ``` 11 | 12 | For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) 13 | 14 | # Available Actions 15 | 16 | ## iOS 17 | 18 | ### ios test 19 | 20 | ```sh 21 | [bundle exec] fastlane ios test 22 | ``` 23 | 24 | Runs all the tests 25 | 26 | ### ios coverage 27 | 28 | ```sh 29 | [bundle exec] fastlane ios coverage 30 | ``` 31 | 32 | Runs code coverage (requires running tests first) 33 | 34 | ### ios increment_build 35 | 36 | ```sh 37 | [bundle exec] fastlane ios increment_build 38 | ``` 39 | 40 | Increments build number 41 | 42 | ### ios app_version 43 | 44 | ```sh 45 | [bundle exec] fastlane ios app_version 46 | ``` 47 | 48 | 49 | 50 | ### ios beta 51 | 52 | ```sh 53 | [bundle exec] fastlane ios beta 54 | ``` 55 | 56 | Submit a new Beta Build to Apple TestFlight 57 | 58 | This will also make sure the profile is up to date 59 | 60 | ### ios release 61 | 62 | ```sh 63 | [bundle exec] fastlane ios release 64 | ``` 65 | 66 | Deploy a new version to the App Store 67 | 68 | ### ios snapshots 69 | 70 | ```sh 71 | [bundle exec] fastlane ios snapshots 72 | ``` 73 | 74 | 75 | 76 | ---- 77 | 78 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 79 | 80 | More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). 81 | 82 | The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 83 | -------------------------------------------------------------------------------- /fastlane/Snapfile: -------------------------------------------------------------------------------- 1 | devices([ 2 | "iPhone SE (3rd generation)", 3 | # "iPhone 15", 4 | "iPhone 15 Pro", 5 | # "iPhone 15 Pro Max", 6 | "iPad Pro (12.9-inch) (6th generation)", 7 | "iPad Pro (12.9-inch) (2nd generation)" 8 | ]) 9 | 10 | languages([ 11 | "en-US", 12 | ]) 13 | 14 | project "./FiveCalls/FiveCalls.xcodeproj" 15 | scheme "FiveCallsUITests" 16 | 17 | output_directory "./fastlane/screenshots" 18 | clear_previous_screenshots true 19 | 20 | # Arguments to pass to the app on launch. See https://github.com/fastlane/snapshot#launch-arguments 21 | launch_arguments(["-hasShownWelcomeScreen false"]) 22 | 23 | # For more information about all available options run 24 | # fastlane snapshot --help 25 | -------------------------------------------------------------------------------- /fastlane/changelog.txt: -------------------------------------------------------------------------------- 1 | 1.4 2 | 3 | - More issues 4 | - Cleaner design 5 | - Support for custom outcomes 6 | 7 | 1.3.23 8 | 9 | - Change to system font 10 | - Remove unnecessary text “Why you’re calling this office” — the value label is enough 11 | - Remove red nav bar, conflicts with system recording UI 12 | - Tappable “Call your representatives” header, sticks to bottom. Tap to scroll the contacts into view 13 | - Location Button rounded border, highlight styles. This makes it look more like a button. 14 | - Reminders: Force the user to choose a day if the reminders are enabled 15 | - Send via=ios on report endpoint for stats 16 | 17 | 1.3.22 18 | 19 | - Use action sheet instead of the custom dropdown popover for additional numbers 20 | - 3D Touch support for previewing issue details 21 | - Call reminders elevated to main screen, with a bell icon indicating if they are turned on or not 22 | - Add weekly streak count to "My Impact" View 23 | - Add some treatment to phone number labels to better indicate you can tap on them 24 | - Move saved call data from caches to app support. This will resolve the issue where low disk space meant that this data would get cleaned up by the OS. (This was unintentionally left in caches) 25 | - Added the version number to the About screen 26 | - Fixed memory leak when fetching issues 27 | - Allow copying issue text 28 | 29 | 30 | Previously: 31 | 32 | - Remove notification badge on app launch 33 | - Fix numeric keyboard when entering full address 34 | - Update About page to match website. 35 | - Add option to remind you to make your calls (Thanks, @chrisbrandow!) 36 | - Fix some memory leaks (Thanks, @tomburns!) 37 | - Better messaging around what to do when the call is placed 38 | - iPad support (Thanks, @bengottlieb!) 39 | - Full Address support for those of you who still had split district results 40 | even after using GPS. (Thanks, @tomburns!) 41 | - Better handling of connection errors, or cases where the server is down 42 | - Fix issue on some devices where the blur effect didn't work, resulting in 43 | broken interface 44 | - Fix a couple of crashes that affected a small number of users 45 | 46 | -------------------------------------------------------------------------------- /screenshots.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5calls/ios/f73e9bbbbfbc4eb64ef06c022e9abcfc47322e27/screenshots.zip -------------------------------------------------------------------------------- /vendor/rswift: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5calls/ios/f73e9bbbbfbc4eb64ef06c022e9abcfc47322e27/vendor/rswift --------------------------------------------------------------------------------