├── .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 | [](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
--------------------------------------------------------------------------------