├── .gitignore
├── Broadcast
├── Assets.xcassets
│ ├── Contents.json
│ ├── AppIcon.appiconset
│ │ ├── app-icon-1024@1x.png
│ │ ├── app-icon-20@1x.png
│ │ ├── app-icon-20@2x-1.png
│ │ ├── app-icon-20@2x.png
│ │ ├── app-icon-20@3x.png
│ │ ├── app-icon-29@1x-1.png
│ │ ├── app-icon-29@1x.png
│ │ ├── app-icon-29@2x-1.png
│ │ ├── app-icon-29@2x.png
│ │ ├── app-icon-29@3x.png
│ │ ├── app-icon-40@1x.png
│ │ ├── app-icon-40@2x-1.png
│ │ ├── app-icon-40@2x.png
│ │ ├── app-icon-40@3x.png
│ │ ├── app-icon-60@2x.png
│ │ ├── app-icon-60@3x.png
│ │ ├── app-icon-76@1x.png
│ │ ├── app-icon-76@2x.png
│ │ ├── app-icon-83.5@2x.png
│ │ └── Contents.json
│ ├── twitter.fill.symbolset
│ │ └── Contents.json
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ └── twitterBrandColor.colorset
│ │ └── Contents.json
├── Preview Content
│ └── Preview Assets.xcassets
│ │ └── Contents.json
├── Helpers
│ ├── TestUtils.swift
│ ├── Binding.extension.swift
│ ├── Debouncer.swift
│ ├── ShakeModifier.swift
│ ├── PersistanceController.swift
│ ├── ThemeHelper.swift
│ ├── Haptics.swift
│ └── TwitterClient.swift
├── Extensions
│ ├── String.extension.swift
│ ├── Array.extension.swift
│ ├── Notification.extension.swift
│ ├── Animation.extension.swift
│ ├── UIApplication.extension.swift
│ ├── UIImage.extension.swift
│ ├── Font.extension.swift
│ ├── TwitterClient+MockTweet.swift
│ └── NSRegularExpression+Convenience.swift
├── TwitterAPI-Info.example.plist
├── Helper Views
│ ├── VisualEffectView.swift
│ ├── EngagementCountersView.swift
│ ├── UserView.swift
│ ├── MentionBar.swift
│ ├── NullStateView.swift
│ ├── RepliesListView.swift
│ ├── AttachmentThumbnail.swift
│ ├── PhotoPicker.swift
│ ├── LastTweetReplyView.swift
│ ├── RemoteImage.swift
│ ├── SafariView.swift
│ ├── TweetView.swift
│ ├── BroadcastButtonStyle.swift
│ ├── DraftsListView.swift
│ ├── ActionBarView.swift
│ └── ComposerView.swift
├── DraftsModel.xcdatamodeld
│ └── DraftsModel.xcdatamodel
│ │ └── contents
├── BroadcastApp.swift
├── WelcomeView.swift
├── Info.plist
├── SignOutView.swift
└── ContentView.swift
├── README.md
├── Broadcast.xcodeproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ ├── IDEWorkspaceChecks.plist
│ │ └── swiftpm
│ │ └── Package.resolved
├── xcuserdata
│ └── dte.xcuserdatad
│ │ ├── xcdebugger
│ │ └── Breakpoints_v2.xcbkptlist
│ │ └── xcschemes
│ │ └── xcschememanagement.plist
├── xcshareddata
│ └── xcschemes
│ │ └── Broadcast.xcscheme
└── project.pbxproj
├── PRIVACY.md
├── BroadcastUITests
├── Info.plist
└── BroadcastUITests.swift
└── LICENSE
/.gitignore:
--------------------------------------------------------------------------------
1 | TwitterAPI-Info.plist
2 |
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Broadcast
2 |
3 | Broadcast is a write-only Twitter client. It lets you post tweets and reply to your last tweet, and that's it.
4 |
--------------------------------------------------------------------------------
/Broadcast/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-1024@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-1024@1x.png
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-20@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-20@1x.png
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-20@2x-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-20@2x-1.png
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-20@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-20@2x.png
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-20@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-20@3x.png
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@1x-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@1x-1.png
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@1x.png
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@2x-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@2x-1.png
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@2x.png
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-29@3x.png
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-40@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-40@1x.png
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-40@2x-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-40@2x-1.png
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-40@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-40@2x.png
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-40@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-40@3x.png
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-60@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-60@2x.png
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-60@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-60@3x.png
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-76@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-76@1x.png
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-76@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-76@2x.png
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-83.5@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/daneden/Broadcast/HEAD/Broadcast/Assets.xcassets/AppIcon.appiconset/app-icon-83.5@2x.png
--------------------------------------------------------------------------------
/Broadcast.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/twitter.fill.symbolset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | },
6 | "symbols" : [
7 | {
8 | "filename" : "twitter.fill.svg",
9 | "idiom" : "universal"
10 | }
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/Broadcast/Helpers/TestUtils.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TestUtils.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 15/08/2021.
6 | //
7 |
8 | import Foundation
9 |
10 | var isTestEnvironment: Bool {
11 | ProcessInfo.processInfo.arguments.contains("isTestEnvironment")
12 | }
13 |
--------------------------------------------------------------------------------
/Broadcast/Extensions/String.extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // String.extension.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 26/06/2021.
6 | //
7 |
8 | import Foundation
9 |
10 | extension String {
11 | var isBlank: Bool {
12 | return allSatisfy({ $0.isWhitespace })
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/Broadcast/Extensions/Array.extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Array.extension.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 27/06/2021.
6 | //
7 |
8 | import Foundation
9 |
10 | extension Array {
11 | func randomElement() -> Element {
12 | return self[Int.random(in: 0...(count-1))]
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Broadcast/Extensions/Notification.extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Notification.extension.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 26/06/2021.
6 | //
7 |
8 | import Foundation
9 |
10 | extension Notification.Name {
11 | static let twitterCallback = Notification.Name(rawValue: "Twitter.CallbackNotification.Name")
12 | }
13 |
--------------------------------------------------------------------------------
/Broadcast/Extensions/Animation.extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Globals.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 02/07/2021.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 |
11 | extension Animation {
12 | static let springAnimation = Animation.interactiveSpring(response: 0.4, dampingFraction: 0.7, blendDuration: 0.4)
13 | }
14 |
--------------------------------------------------------------------------------
/Broadcast/Extensions/UIApplication.extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIApplication.extension.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 27/06/2021.
6 | //
7 |
8 | import Foundation
9 | import UIKit
10 |
11 | extension UIApplication {
12 | func endEditing() {
13 | sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "242",
9 | "green" : "161",
10 | "red" : "29"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/twitterBrandColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.949",
9 | "green" : "0.631",
10 | "red" : "0.114"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/PRIVACY.md:
--------------------------------------------------------------------------------
1 | # Broadcast Privacy Policy
2 |
3 | We do not collect any of your data. All data retrieved from Twitter’s API is kept local to your device and is immediately removed when signing out from or deleting the app.
4 |
5 | We don't display any ads, and never will. We don't track you using an advertising identifier or anything of the sort.
6 |
7 | If you have any issues to report or feedback about this privacy policy or anything else, you can do it using GitHub issues on this repository.
8 |
--------------------------------------------------------------------------------
/Broadcast/TwitterAPI-Info.example.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | __TEST_AUTH_QUERY_STRING
6 | Enter auth query string here (necessary for tests)
7 | API_SECRET
8 | Enter API Secret Here
9 | API_KEY
10 | Enter API Key Here
11 |
12 |
13 |
--------------------------------------------------------------------------------
/Broadcast/Helper Views/VisualEffectView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // VisualEffectView.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 27/06/2021.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 |
11 | struct VisualEffectView: UIViewRepresentable {
12 | var effect: UIVisualEffect?
13 | func makeUIView(context: UIViewRepresentableContext) -> UIVisualEffectView { UIVisualEffectView() }
14 | func updateUIView(_ uiView: UIVisualEffectView, context: UIViewRepresentableContext) { uiView.effect = effect }
15 | }
16 |
--------------------------------------------------------------------------------
/Broadcast/Helpers/Binding.extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Binding.extension.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 26/06/2021.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 |
11 | public extension Binding where Value: Equatable {
12 | init(_ source: Binding, replacingNilWith nilProxy: Value) {
13 | self.init(
14 | get: { source.wrappedValue ?? nilProxy },
15 | set: { newValue in
16 | if newValue == nilProxy {
17 | source.wrappedValue = nil
18 | }
19 | else {
20 | source.wrappedValue = newValue
21 | }
22 | })
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Broadcast/Extensions/UIImage.extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIImage.extension.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 10/07/2021.
6 | //
7 |
8 | import Foundation
9 | import UIKit
10 |
11 | extension UIImage {
12 | func fixOrientation(_ img: UIImage) -> UIImage {
13 | if (img.imageOrientation == .up) {
14 | return img
15 | }
16 |
17 | UIGraphicsBeginImageContextWithOptions(img.size, false, img.scale)
18 | let rect = CGRect(x: 0, y: 0, width: img.size.width, height: img.size.height)
19 | img.draw(in: rect)
20 |
21 | let normalizedImage = UIGraphicsGetImageFromCurrentImageContext()!
22 | UIGraphicsEndImageContext()
23 |
24 | return normalizedImage
25 | }
26 |
27 | var fixedOrientation: UIImage {
28 | return fixOrientation(self)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/BroadcastUITests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Broadcast/Extensions/Font.extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Font.extension.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 27/06/2021.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 |
11 | extension Font {
12 | static var broadcastBody: Font = .system(.body, design: .rounded)
13 | static var broadcastTitle: Font = .system(.title, design: .rounded)
14 | static var broadcastTitle2: Font = .system(.title2, design: .rounded)
15 | static var broadcastTitle3: Font = .system(.title3, design: .rounded)
16 | static var broadcastLargeTitle: Font = .system(.largeTitle, design: .rounded)
17 | static var broadcastFootnote: Font = .system(.footnote, design: .rounded)
18 | static var broadcastCaption: Font = .system(.caption, design: .rounded)
19 | static var broadcastHeadline: Font = .system(.headline, design: .rounded)
20 | }
21 |
--------------------------------------------------------------------------------
/Broadcast/Extensions/TwitterClient+MockTweet.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TwitterClient+MockTweet.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 01/08/2021.
6 | //
7 |
8 | import Foundation
9 |
10 | extension TwitterClient.Tweet {
11 | static var mockTweet: TwitterClient.Tweet {
12 | TwitterClient.Tweet(
13 | numericId: 0,
14 | id: "0",
15 | text: "just setting up my twttr",
16 | likes: 420,
17 | retweets: 69,
18 | date: Date(),
19 | author: .mockUser
20 | )
21 | }
22 | }
23 |
24 | extension TwitterClient.User {
25 | static var mockUser: TwitterClient.User {
26 | TwitterClient.User(
27 | id: "0",
28 | screenName: "_dte",
29 | originalProfileImageURL: URL(string: "https://pbs.twimg.com/profile_images/1337359860409790469/javRMXyG_x96.jpg")!
30 | )
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Broadcast.xcodeproj/xcuserdata/dte.xcuserdatad/xcschemes/xcschememanagement.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SchemeUserState
6 |
7 | Broadcast.xcscheme_^#shared#^_
8 |
9 | orderHint
10 | 0
11 |
12 | BroadcastTests.xcscheme_^#shared#^_
13 |
14 | orderHint
15 | 1
16 |
17 |
18 | SuppressBuildableAutocreation
19 |
20 | 711EF99026C959A700FD8A9F
21 |
22 | primary
23 |
24 |
25 | 7188E6242687B0FE007CFD78
26 |
27 | primary
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/Broadcast/Helpers/Debouncer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Debouncer.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 03/08/2021.
6 | //
7 |
8 | import Foundation
9 |
10 | public class Debouncer {
11 | private let timeInterval: TimeInterval
12 | private var timer: Timer?
13 |
14 | typealias Handler = () -> Void
15 | var handler: Handler?
16 |
17 | init(timeInterval: TimeInterval) {
18 | self.timeInterval = timeInterval
19 | }
20 |
21 | public func renewInterval() {
22 | timer?.invalidate()
23 | timer = Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: false, block: { [weak self] (timer) in
24 | self?.timeIntervalDidFinish(for: timer)
25 | })
26 | }
27 |
28 | @objc private func timeIntervalDidFinish(for timer: Timer) {
29 | guard timer.isValid else {
30 | return
31 | }
32 |
33 | handler?()
34 | handler = nil
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Broadcast/DraftsModel.xcdatamodeld/DraftsModel.xcdatamodel/contents:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/Broadcast/Helper Views/EngagementCountersView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EngagementCountersView.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 03/07/2021.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct EngagementCountersView: View {
11 | var tweet: TwitterClient.Tweet
12 |
13 | var repliesString: String {
14 | let replyCount = tweet.replies?.count ?? 0
15 | switch replyCount {
16 | case 0:
17 | return "No replies"
18 | case 1:
19 | return "1 Reply"
20 | default:
21 | return "\(replyCount) Replies"
22 | }
23 | }
24 |
25 | var body: some View {
26 | Label(repliesString, systemImage: "arrowshape.turn.up.left")
27 | .font(.broadcastCaption)
28 | .foregroundColor(.accentColor)
29 | }
30 | }
31 |
32 | struct EngagementCountersView_Previews: PreviewProvider {
33 | static var previews: some View {
34 | EngagementCountersView(tweet: TwitterClient.Tweet(likes: 420, retweets: 69, replies: []))
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Broadcast/Helper Views/UserView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserView.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 03/08/2021.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct UserView: View {
11 | @ScaledMetric var avatarSize: CGFloat = 24
12 |
13 | var user: TwitterClient.User
14 | var body: some View {
15 | HStack {
16 | if let imageUrl = user.profileImageURL {
17 | RemoteImage(url: imageUrl, placeholder: { ProgressView() })
18 | .aspectRatio(contentMode: .fill)
19 | .frame(width: avatarSize, height: avatarSize)
20 | .cornerRadius(36)
21 | }
22 |
23 | VStack(alignment: .leading) {
24 | if let name = user.name {
25 | Text(name)
26 | .fontWeight(.bold)
27 | }
28 |
29 | Text("@\(user.screenName)")
30 | .foregroundColor(.secondary)
31 | }
32 | }.font(.broadcastFootnote)
33 | }
34 | }
35 |
36 | struct UserView_Previews: PreviewProvider {
37 | static var previews: some View {
38 | UserView(user: .mockUser)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Broadcast/Helper Views/MentionBar.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MentionBar.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 03/08/2021.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct MentionBar: View {
11 | var users: [TwitterClient.User]
12 | var tapHandler: (TwitterClient.User) -> Void = { _ in }
13 |
14 | var body: some View {
15 | ScrollView(.horizontal) {
16 | HStack {
17 | ForEach(users, id: \.id) { user in
18 | UserView(user: user)
19 | .padding(8)
20 | .background(VisualEffectView(effect: UIBlurEffect(style: .systemMaterial)))
21 | .cornerRadius(6)
22 | .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 6)
23 | .onTapGesture {
24 | tapHandler(user)
25 | }
26 | }
27 | }.padding(12)
28 | }
29 | .background(VisualEffectView(effect: UIBlurEffect(style: .prominent)))
30 | }
31 | }
32 |
33 | struct MentionBar_Previews: PreviewProvider {
34 | static var previews: some View {
35 | MentionBar(users: [.mockUser])
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Daniel Eden
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 |
--------------------------------------------------------------------------------
/Broadcast/Helper Views/NullStateView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NullStateView.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 14/08/2021.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct NullStateView: View {
11 | enum ViewType {
12 | case replies, drafts
13 | }
14 |
15 | var type: ViewType
16 |
17 | var imageName: String {
18 | switch type {
19 | case .drafts:
20 | return "doc.on.doc"
21 | case .replies:
22 | return "arrowshape.turn.up.left"
23 | }
24 | }
25 |
26 | var label: String {
27 | switch type {
28 | case .drafts:
29 | return "No Drafts"
30 | case .replies:
31 | return "No Replies"
32 | }
33 | }
34 |
35 | var body: some View {
36 | VStack {
37 | Spacer()
38 | VStack(spacing: 8) {
39 | Image(systemName: imageName)
40 | .imageScale(.large)
41 | Text(label)
42 | }
43 | .font(.broadcastTitle2)
44 | .foregroundColor(.secondary)
45 | Spacer()
46 | }
47 | }
48 | }
49 |
50 | struct NullStateView_Previews: PreviewProvider {
51 | static var previews: some View {
52 | NullStateView(type: .drafts)
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/Broadcast/BroadcastApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BroadcastApp.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 26/06/2021.
6 | //
7 |
8 | import SwiftUI
9 |
10 | @main
11 | struct BroadcastApp: App {
12 | @Environment(\.scenePhase) var scenePhase
13 | @StateObject var themeHelper = ThemeHelper.shared
14 | @StateObject var twitterClient = TwitterClient()
15 | let persistenceController = PersistanceController.shared
16 |
17 | var body: some Scene {
18 | WindowGroup {
19 | GeometryReader { geom in
20 | ZStack(alignment: .top) {
21 | ContentView()
22 | .environmentObject(twitterClient)
23 | .environmentObject(themeHelper)
24 | .environment(\.managedObjectContext, persistenceController.container.viewContext)
25 | .accentColor(themeHelper.color)
26 | .onChange(of: scenePhase) { newPhase in
27 | if newPhase == .active {
28 | twitterClient.revalidateAccount()
29 | }
30 |
31 | persistenceController.save()
32 | }
33 |
34 | VisualEffectView(effect: UIBlurEffect(style: .regular))
35 | .frame(height: geom.safeAreaInsets.top)
36 | .ignoresSafeArea(.all, edges: .top)
37 | }
38 | }
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Broadcast/Helper Views/RepliesListView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RepliesListView.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 01/08/2021.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct RepliesListView: View {
11 | @Environment(\.presentationMode) var presentationMode
12 | var tweet: TwitterClient.Tweet?
13 |
14 | var body: some View {
15 | NavigationView {
16 | Group {
17 | if let tweet = tweet, let replies = tweet.replies, !replies.isEmpty {
18 | List {
19 | ForEach(replies, id: \.id) { reply in
20 | TweetView(tweet: reply)
21 | .onTapGesture {
22 | guard let screenName = reply.author?.screenName,
23 | let tweetId = reply.id else { return }
24 | let url = URL(string: "https://twitter.com/\(screenName)/status/\(tweetId)")
25 |
26 | UIApplication.shared.open(url!)
27 | }
28 | }
29 | }
30 | } else {
31 | NullStateView(type: .replies)
32 | }
33 | }.navigationTitle("Replies")
34 | .toolbar {
35 | Button("Close") {
36 | presentationMode.wrappedValue.dismiss()
37 | }
38 | }
39 | }
40 | }
41 | }
42 |
43 | struct RepliesListView_Previews: PreviewProvider {
44 | static var previews: some View {
45 | RepliesListView()
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/Broadcast/Helper Views/AttachmentThumbnail.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ThumbnailFilmstrip.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 26/06/2021.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct AttachmentThumbnail: View {
11 | @Binding var image: UIImage?
12 |
13 | var body: some View {
14 | Group {
15 | if let image = image {
16 | ZStack(alignment: .topTrailing) {
17 | Image(uiImage: image)
18 | .resizable()
19 | .aspectRatio(image.size, contentMode: .fill)
20 | .clipShape(RoundedRectangle(cornerRadius: 8))
21 |
22 | Button(action: removeImage) {
23 | Label("Remove Image", systemImage: "xmark.circle")
24 | .labelStyle(IconOnlyLabelStyle())
25 | .font(.broadcastTitle.bold())
26 | .foregroundColor(.white)
27 | .shadow(color: .black, radius: 8, x: 0, y: 4)
28 | }
29 | .buttonStyle(BroadcastButtonStyle(paddingSize: -2, prominence: .tertiary, isFullWidth: false))
30 | .clipShape(Circle())
31 | .offset(x: -8, y: 8)
32 | }
33 | }
34 | }.transition(.opacity)
35 |
36 | }
37 |
38 | func removeImage() {
39 | withAnimation { image = nil }
40 | }
41 | }
42 |
43 | struct ThumbnailFilmstrip_Previews: PreviewProvider {
44 | static var previews: some View {
45 | AttachmentThumbnail(image: .constant(nil))
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/Broadcast/Helpers/ShakeModifier.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ShakeModifier.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 28/06/2021.
6 | //
7 |
8 | import Foundation
9 | import UIKit
10 | import SwiftUI
11 |
12 | // The notification we'll send when a shake gesture happens.
13 | extension UIDevice {
14 | static let deviceDidShakeNotification = Notification.Name(rawValue: "deviceDidShakeNotification")
15 | }
16 |
17 | // Override the default behavior of shake gestures to send our notification instead.
18 | extension UIWindow {
19 | open override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
20 | if motion == .motionShake {
21 | NotificationCenter.default.post(name: UIDevice.deviceDidShakeNotification, object: nil)
22 | }
23 | }
24 | }
25 |
26 | // A view modifier that detects shaking and calls a function of our choosing.
27 | struct DeviceShakeViewModifier: ViewModifier {
28 | let action: () -> Void
29 |
30 | func body(content: Content) -> some View {
31 | content
32 | .onAppear()
33 | .onReceive(NotificationCenter.default.publisher(for: UIDevice.deviceDidShakeNotification)) { _ in
34 | action()
35 | }
36 | }
37 | }
38 |
39 | // A View extension to make the modifier easier to use.
40 | extension View {
41 | func onShake(perform action: @escaping () -> Void) -> some View {
42 | self.modifier(DeviceShakeViewModifier(action: action))
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/Broadcast/Helper Views/PhotoPicker.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PhotoPicker.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 26/06/2021.
6 | //
7 |
8 | import Foundation
9 | import UIKit
10 | import SwiftUI
11 |
12 | struct ImagePicker: UIViewControllerRepresentable {
13 | @Environment(\.presentationMode) var presentationMode
14 | @Binding var chosenImage: UIImage?
15 |
16 | func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController {
17 | let picker = UIImagePickerController()
18 | picker.delegate = context.coordinator
19 | return picker
20 | }
21 |
22 | func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext) {
23 |
24 | }
25 |
26 | func makeCoordinator() -> Coordinator {
27 | Coordinator(self)
28 | }
29 |
30 | class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
31 | let parent: ImagePicker
32 |
33 | init(_ parent: ImagePicker) {
34 | self.parent = parent
35 | }
36 |
37 | func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
38 | if let image = info[.originalImage] as? UIImage {
39 | parent.chosenImage = image
40 | }
41 |
42 | parent.presentationMode.wrappedValue.dismiss()
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Broadcast/Helper Views/LastTweetReplyView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LastTweetReplyView.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 02/07/2021.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct LastTweetReplyView: View {
11 | @ScaledMetric var spacing: CGFloat = 4
12 | var lastTweet: TwitterClient.Tweet
13 |
14 | var body: some View {
15 | VStack(alignment: .leading, spacing: spacing) {
16 | HStack(spacing: 0) {
17 | Label(title: {
18 | VStack(alignment: .leading, spacing: spacing) {
19 | Text("Replying to last tweet")
20 | .fontWeight(.semibold)
21 | .foregroundColor(.accentColor)
22 | if let tweetText = lastTweet.text {
23 | Text(tweetText)
24 | .foregroundColor(.secondary)
25 | }
26 |
27 | EngagementCountersView(tweet: lastTweet)
28 | }
29 | }, icon: {
30 | Image(systemName: "arrowshape.turn.up.left.fill")
31 | .foregroundColor(.accentColor)
32 | })
33 | .font(.broadcastFootnote)
34 |
35 | Spacer(minLength: 0)
36 | }
37 | }
38 | .padding(spacing * 2)
39 | .background(Color.accentColor.opacity(0.1))
40 | .cornerRadius(spacing * 2)
41 | }
42 | }
43 |
44 | struct LastTweetReplyView_Previews: PreviewProvider {
45 | static var previews: some View {
46 | LastTweetReplyView(lastTweet: TwitterClient.Tweet(text: "Example tweet"))
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/Broadcast/Helpers/PersistanceController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PersistanceController.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 10/07/2021.
6 | //
7 |
8 | import Foundation
9 | import CoreData
10 |
11 | struct PersistanceController {
12 | static let shared = PersistanceController()
13 |
14 | let container: NSPersistentContainer
15 | var context: NSManagedObjectContext {
16 | container.viewContext
17 | }
18 |
19 | static var preview: PersistanceController {
20 | let controller = PersistanceController(inMemory: true)
21 |
22 | for i in 0..<10 {
23 | let draft = Draft(context: controller.container.viewContext)
24 | draft.text = "Test draft \(i)"
25 | draft.date = Date()
26 | draft.id = UUID()
27 | }
28 |
29 | return controller
30 | }
31 |
32 | init(inMemory: Bool = false) {
33 | container = NSPersistentContainer(name: "DraftsModel")
34 |
35 | if inMemory {
36 | container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
37 | }
38 |
39 | container.loadPersistentStores { description, error in
40 | if let error = error {
41 | fatalError("Error: \(error.localizedDescription)")
42 | }
43 | }
44 | }
45 |
46 | func save() {
47 | let context = container.viewContext
48 |
49 | if context.hasChanges {
50 | do {
51 | try context.save()
52 | } catch {
53 | print(error.localizedDescription)
54 | }
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/Broadcast/Helper Views/RemoteImage.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RemoteImage.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 28/06/2021.
6 | //
7 |
8 | import SwiftUI
9 | import Combine
10 | import UIKit
11 |
12 | class ImageLoader: ObservableObject {
13 | @Published var image: UIImage?
14 | private let url: URL
15 |
16 | init(url: URL) {
17 | self.url = url
18 | }
19 |
20 | deinit {
21 | cancel()
22 | }
23 |
24 | private var cancellable: AnyCancellable?
25 |
26 | func load() {
27 | cancellable = URLSession.shared.dataTaskPublisher(for: url)
28 | .map { UIImage(data: $0.data) }
29 | .replaceError(with: nil)
30 | .receive(on: DispatchQueue.main)
31 | .sink { [weak self] image in
32 | withAnimation { self?.image = image }
33 | }
34 | }
35 |
36 | func cancel() {
37 | cancellable?.cancel()
38 | }
39 | }
40 |
41 | struct RemoteImage: View {
42 | @StateObject private var loader: ImageLoader
43 | private let placeholder: Placeholder
44 |
45 | init(url: URL, @ViewBuilder placeholder: () -> Placeholder) {
46 | self.placeholder = placeholder()
47 | _loader = StateObject(wrappedValue: ImageLoader(url: url))
48 | }
49 |
50 | var body: some View {
51 | content
52 | .onAppear(perform: loader.load)
53 | }
54 |
55 | private var content: some View {
56 | Group {
57 | if loader.image != nil {
58 | Image(uiImage: loader.image!)
59 | .resizable()
60 | } else {
61 | placeholder
62 | }
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/Broadcast/Helpers/ThemeHelper.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ThemeHelper.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 27/06/2021.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 | import SwiftUI
11 |
12 | extension Color: RawRepresentable {
13 | public init?(rawValue: String) {
14 | guard let data = Data(base64Encoded: rawValue) else{
15 | self = .black
16 | return
17 | }
18 |
19 | do {
20 | let color = try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? UIColor ?? .black
21 | self = Color(color)
22 | }catch{
23 | self = .accentColor
24 | }
25 | }
26 |
27 | public var rawValue: String {
28 | do {
29 | let data = try NSKeyedArchiver.archivedData(withRootObject: UIColor(self), requiringSecureCoding: false) as Data
30 | return data.base64EncodedString()
31 | } catch {
32 | return ""
33 | }
34 | }
35 | }
36 |
37 | class ThemeHelper: ObservableObject {
38 | static let shared = ThemeHelper()
39 |
40 | @AppStorage("themeColor") var color = Color("twitterBrandColor")
41 |
42 | @AppStorage("themeColorIndex") private var currentColorIndex = 0 {
43 | didSet {
44 | color = allColors[currentColorIndex]
45 | }
46 | }
47 |
48 | private var allColors: [Color] = [
49 | Color("twitterBrandColor"),
50 | Color(.systemIndigo),
51 | Color(.systemPurple),
52 | Color(.systemPink),
53 | Color(.systemOrange),
54 | Color(.systemGreen),
55 | Color(.systemTeal),
56 | ]
57 |
58 | func rotateTheme() {
59 | currentColorIndex = (currentColorIndex + 1) % allColors.count
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "swifter",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/daneden/Swifter",
7 | "state" : {
8 | "revision" : "21a1cf736971d07dec56cf5cc0294f31a34ec528",
9 | "version" : "2.5.1"
10 | }
11 | },
12 | {
13 | "identity" : "swiftkeychainwrapper",
14 | "kind" : "remoteSourceControl",
15 | "location" : "https://github.com/jrendel/SwiftKeychainWrapper",
16 | "state" : {
17 | "revision" : "185a3165346a03767101c4f62e9a545a0fe0530f",
18 | "version" : "4.0.1"
19 | }
20 | },
21 | {
22 | "identity" : "swiftui-introspect",
23 | "kind" : "remoteSourceControl",
24 | "location" : "https://github.com/siteline/SwiftUI-Introspect",
25 | "state" : {
26 | "revision" : "2e09be8af614401bc9f87d40093ec19ce56ccaf2",
27 | "version" : "0.1.3"
28 | }
29 | },
30 | {
31 | "identity" : "twitter-text",
32 | "kind" : "remoteSourceControl",
33 | "location" : "https://github.com/nysander/twitter-text.git",
34 | "state" : {
35 | "revision" : "09fb2896ca4bec3b17513de2da9376559062116d",
36 | "version" : "0.0.1"
37 | }
38 | },
39 | {
40 | "identity" : "unicodeurl",
41 | "kind" : "remoteSourceControl",
42 | "location" : "https://github.com/nysander/UnicodeURL.git",
43 | "state" : {
44 | "revision" : "fe15c8b585c2e6d22dfe81da899183e6aa376a93",
45 | "version" : "0.1.0"
46 | }
47 | }
48 | ],
49 | "version" : 2
50 | }
51 |
--------------------------------------------------------------------------------
/Broadcast/Extensions/NSRegularExpression+Convenience.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSRegularExpression+Convenience.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 03/08/2021.
6 | //
7 |
8 | import Foundation
9 |
10 | extension NSRegularExpression {
11 | convenience init(_ pattern: String) {
12 | do {
13 | try self.init(pattern: pattern)
14 | } catch {
15 | preconditionFailure("Illegal regular expression: \(pattern).")
16 | }
17 | }
18 |
19 | convenience init(_ pattern: String, options: NSRegularExpression.Options) {
20 | do {
21 | try self.init(pattern: pattern, options: options)
22 | } catch {
23 | preconditionFailure("Illegal regular expression: \(pattern).")
24 | }
25 | }
26 | }
27 |
28 | extension NSRegularExpression {
29 | func matches(_ string: String) -> Bool {
30 | let range = NSRange(location: 0, length: string.utf16.count)
31 | return firstMatch(in: string, options: [], range: range) != nil
32 | }
33 |
34 | func firstMatchAsString(_ string: String) -> String? {
35 | let nsString = string as NSString
36 | let range = NSRange(location: 0, length: string.utf16.count)
37 | guard let firstMatch = firstMatch(in: string, options: [], range: range) else {
38 | return nil
39 | }
40 |
41 | return nsString.substring(with: firstMatch.range)
42 | }
43 | }
44 |
45 | extension String {
46 | static func ~= (lhs: String, rhs: String) -> Bool {
47 | guard let regex = try? NSRegularExpression(pattern: rhs) else { return false }
48 | let range = NSRange(location: 0, length: lhs.utf16.count)
49 | return regex.firstMatch(in: lhs, options: [], range: range) != nil
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/Broadcast/Helper Views/SafariView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SafariView.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 26/06/2021.
6 | //
7 |
8 | import SwiftUI
9 | import SafariServices
10 |
11 | struct SafariView: UIViewControllerRepresentable {
12 | class SafariViewControllerWrapper: UIViewController {
13 | private var safariViewController: SFSafariViewController?
14 |
15 | var url: URL? {
16 | didSet {
17 | if let safariViewController = safariViewController {
18 | safariViewController.willMove(toParent: self)
19 | safariViewController.view.removeFromSuperview()
20 | safariViewController.removeFromParent()
21 | self.safariViewController = nil
22 | }
23 |
24 | guard let url = url else { return }
25 |
26 | let newSafariViewController = SFSafariViewController(url: url)
27 | addChild(newSafariViewController)
28 | newSafariViewController.view.frame = view.frame
29 | view.addSubview(newSafariViewController.view)
30 | newSafariViewController.didMove(toParent: self)
31 | self.safariViewController = newSafariViewController
32 | }
33 | }
34 |
35 | override func viewDidLoad() {
36 | super.viewDidLoad()
37 | self.url = nil
38 | }
39 | }
40 |
41 | typealias UIViewControllerType = SafariViewControllerWrapper
42 |
43 | @Binding var url: URL?
44 |
45 | func makeUIViewController(context: UIViewControllerRepresentableContext) -> SafariViewControllerWrapper {
46 | return SafariViewControllerWrapper()
47 | }
48 |
49 | func updateUIViewController(_ safariViewControllerWrapper: SafariViewControllerWrapper,
50 | context: UIViewControllerRepresentableContext) {
51 | safariViewControllerWrapper.url = url
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/Broadcast/Helper Views/TweetView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TweetView.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 01/08/2021.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct TweetView: View {
11 | @ScaledMetric private var avatarSize: CGFloat = 36
12 | @ScaledMetric private var padding: CGFloat = 4
13 | var tweet: TwitterClient.Tweet
14 |
15 | var formatter: RelativeDateTimeFormatter {
16 | let formatter = RelativeDateTimeFormatter()
17 | formatter.dateTimeStyle = .named
18 | formatter.unitsStyle = .short
19 | formatter.formattingContext = .standalone
20 |
21 | return formatter
22 | }
23 |
24 | var body: some View {
25 | HStack(alignment: .top) {
26 | if let imageUrl = tweet.author?.profileImageURL {
27 | RemoteImage(url: imageUrl, placeholder: { ProgressView() })
28 | .aspectRatio(contentMode: .fill)
29 | .frame(width: avatarSize, height: avatarSize)
30 | .cornerRadius(36)
31 | }
32 |
33 | VStack(alignment: .leading, spacing: 4) {
34 | HStack {
35 | if let tweetAuthorName = tweet.author?.name,
36 | let screenName = tweet.author?.screenName,
37 | let date = tweet.date {
38 | Text("\(Text(tweetAuthorName).fontWeight(.bold).foregroundColor(.primary)) \(Text("@\(screenName)")) • \(Text(formatter.localizedString(for: date, relativeTo: Date())))")
39 | .foregroundColor(.secondary)
40 | }
41 | }
42 | .lineLimit(1)
43 |
44 | if let tweetText = tweet.text {
45 | Text(tweetText).lineSpacing(0)
46 | }
47 | }
48 | }
49 | .font(.broadcastFootnote)
50 | .padding(.vertical, padding)
51 | }
52 | }
53 |
54 | struct TweetView_Previews: PreviewProvider {
55 | static var previews: some View {
56 | TweetView(tweet: TwitterClient.Tweet.mockTweet)
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/Broadcast/Helpers/Haptics.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Haptics.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 29/06/2021.
6 | //
7 |
8 | import Foundation
9 | import CoreHaptics
10 | import UIKit
11 |
12 | class Haptics {
13 | private var engine: CHHapticEngine?
14 | static let shared = Haptics()
15 |
16 | static var isSupported: Bool {
17 | CHHapticEngine.capabilitiesForHardware().supportsHaptics
18 | }
19 |
20 | init() {
21 | prepareHaptics()
22 | }
23 |
24 | deinit {
25 | engine?.stop()
26 | }
27 |
28 | func sendFeedback(
29 | intensity: Float,
30 | sharpness: Float
31 | ) {
32 | try? engine?.start()
33 |
34 | guard Haptics.isSupported else { return }
35 | var events = [CHHapticEvent]()
36 |
37 | let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity)
38 | let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness)
39 | let event = CHHapticEvent(eventType: .hapticTransient, parameters: [intensity, sharpness], relativeTime: 0)
40 | events.append(event)
41 |
42 | do {
43 | let pattern = try CHHapticPattern(events: events, parameters: [])
44 | let player = try engine?.makePlayer(with: pattern)
45 | try player?.start(atTime: 0)
46 | } catch {
47 | print("Failed to play pattern: \(error.localizedDescription).")
48 | }
49 | }
50 |
51 | func sendStandardFeedback(feedbackType: UINotificationFeedbackGenerator.FeedbackType) {
52 | let generator = UINotificationFeedbackGenerator()
53 | generator.notificationOccurred(feedbackType)
54 | }
55 |
56 | func prepareHaptics() {
57 | guard Haptics.isSupported else { return }
58 |
59 | do {
60 | self.engine = try CHHapticEngine()
61 |
62 | try engine?.start()
63 | } catch {
64 | print("There was an error creating the engine: \(error.localizedDescription)")
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/Broadcast/WelcomeView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WelcomeView.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 27/06/2021.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct WelcomeView: View {
11 | @EnvironmentObject var themeHelper: ThemeHelper
12 | @ScaledMetric var spacing: CGFloat = 24
13 |
14 | @State var rotation: Double = -3
15 |
16 | var animation = Animation.interactiveSpring(response: 0.2, dampingFraction: 0.2, blendDuration: 0.4)
17 |
18 | var body: some View {
19 | VStack(alignment: .leading, spacing: spacing) {
20 | Spacer()
21 |
22 | HStack {
23 | Spacer()
24 | Label("Broadcast", systemImage: "exclamationmark.bubble.fill")
25 | .font(.broadcastLargeTitle.weight(.heavy))
26 | .foregroundColor(.white)
27 | .rotationEffect(Angle(degrees: rotation))
28 | .padding()
29 | .background(
30 | RoundedRectangle(cornerRadius: spacing, style: .continuous)
31 | .rotation(Angle(degrees: rotation / 2))
32 | .fill(Color.accentColor)
33 | )
34 | .onTapGesture {
35 | themeHelper.rotateTheme()
36 | Haptics.shared.sendStandardFeedback(feedbackType: .success)
37 |
38 | withAnimation(animation) {
39 | rotation = Double.random(in: -7...7)
40 | }
41 | }
42 | Spacer()
43 | }
44 |
45 | Spacer()
46 |
47 | Group {
48 | Text("Broadcast is a Twitter app like no other: it’s write-only.")
49 | .rotationEffect(Angle(degrees: rotation * 0.125))
50 | Text("No notifications, likes, retweets, ads, replies, sliding-into-DMs, lists—not even a timeline.")
51 | .rotationEffect(Angle(degrees: rotation * -0.2))
52 | Text("Tweet like nobody’s watching.")
53 | .rotationEffect(Angle(degrees: rotation * 0.3))
54 | }.font(.broadcastTitle.bold())
55 |
56 | Spacer()
57 | }
58 | }
59 | }
60 |
61 | struct WelcomeView_Previews: PreviewProvider {
62 | static var previews: some View {
63 | WelcomeView()
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/Broadcast/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | $(MARKETING_VERSION)
19 | CFBundleURLTypes
20 |
21 |
22 | CFBundleTypeRole
23 | Editor
24 | CFBundleURLSchemes
25 |
26 | twitter-broadcast
27 |
28 |
29 |
30 | CFBundleVersion
31 | $(CURRENT_PROJECT_VERSION)
32 | ITSAppUsesNonExemptEncryption
33 |
34 | LSRequiresIPhoneOS
35 |
36 | NSPhotoLibraryUsageDescription
37 | Allow permission to enable tweeting photos
38 | UIApplicationSceneManifest
39 |
40 | UIApplicationSupportsMultipleScenes
41 |
42 |
43 | UIApplicationSupportsIndirectInputEvents
44 |
45 | UILaunchScreen
46 |
47 | UIRequiredDeviceCapabilities
48 |
49 | armv7
50 |
51 | UISupportedInterfaceOrientations
52 |
53 | UIInterfaceOrientationPortrait
54 | UIInterfaceOrientationLandscapeLeft
55 | UIInterfaceOrientationLandscapeRight
56 |
57 | UISupportedInterfaceOrientations~ipad
58 |
59 | UIInterfaceOrientationPortrait
60 | UIInterfaceOrientationPortraitUpsideDown
61 | UIInterfaceOrientationLandscapeLeft
62 | UIInterfaceOrientationLandscapeRight
63 |
64 |
65 |
66 |
--------------------------------------------------------------------------------
/Broadcast/Helper Views/BroadcastButtonStyle.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BroadcastButtonStyle.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 26/06/2021.
6 | //
7 |
8 | import SwiftUI
9 | import CoreHaptics
10 |
11 | struct BroadcastLabelStyle: LabelStyle {
12 | enum Appearance {
13 | case iconOnly, normal
14 | }
15 |
16 | var appearance: Appearance = .normal
17 | var accessibilityLabel: String
18 |
19 | func makeBody(configuration: Configuration) -> some View {
20 | HStack(alignment: .firstTextBaseline) {
21 | configuration.icon
22 | if appearance == .normal {
23 | configuration.title
24 | .accessibility(hidden: true)
25 | }
26 | }.accessibilityLabel(accessibilityLabel)
27 | }
28 | }
29 |
30 | struct BroadcastButtonStyle: ButtonStyle {
31 | enum Prominence {
32 | case primary, secondary, tertiary, destructive
33 | }
34 |
35 | @ScaledMetric var paddingSize: CGFloat = 16
36 | var prominence: Prominence = .primary
37 | var isFullWidth = true
38 | var isLoading = false
39 |
40 | var background: some View {
41 | Group {
42 | switch prominence {
43 | case .primary:
44 | Color.accentColor
45 | case .secondary:
46 | Color.accentColor.opacity(0.1)
47 | case .tertiary:
48 | VisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial))
49 | case .destructive:
50 | Color(.systemRed)
51 | }
52 | }
53 | }
54 |
55 | var foregroundColor: Color {
56 | switch prominence {
57 | case .secondary:
58 | return .accentColor
59 | case .tertiary:
60 | return .primary
61 | default:
62 | return .white
63 | }
64 | }
65 |
66 | func makeBody(configuration: Configuration) -> some View {
67 | HStack {
68 | if isFullWidth { Spacer(minLength: 0) }
69 |
70 | configuration.label
71 | .font(.broadcastBody.bold())
72 | .opacity(configuration.isPressed ? 0.8 : 1)
73 | .opacity(isLoading ? 0 : 1)
74 |
75 | if isFullWidth { Spacer(minLength: 0) }
76 | }
77 | .padding(paddingSize)
78 | .background(background.padding(-paddingSize))
79 | .foregroundColor(foregroundColor)
80 | .overlay(
81 | Group {
82 | if isLoading {
83 | ProgressView()
84 | }
85 | }
86 | )
87 | .clipShape(Capsule())
88 | .scaleEffect(configuration.isPressed ? 0.95 : 1)
89 | .animation(.interactiveSpring(), value: configuration.isPressed)
90 | .onChange(of: configuration.isPressed) { isPressed in
91 | Haptics.shared.sendFeedback(
92 | intensity: isPressed ? 0.8 : 0.4,
93 | sharpness: isPressed ? 1 : 0.7
94 | )
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/Broadcast/Helper Views/DraftsListView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DraftsListView.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 03/07/2021.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct DraftsListView: View {
11 | @ScaledMetric var thumbnailSize: CGFloat = 56
12 | @Environment(\.presentationMode) var presentationMode
13 | @Environment(\.managedObjectContext) var managedObjectContext
14 |
15 | @FetchRequest(entity: Draft.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \Draft.date, ascending: true)])
16 | var drafts: FetchedResults
17 |
18 | @EnvironmentObject var twitterClient: TwitterClient
19 | @EnvironmentObject var themeHelper: ThemeHelper
20 |
21 | var body: some View {
22 | NavigationView {
23 | Group {
24 | if drafts.isEmpty {
25 | NullStateView(type: .drafts)
26 | } else {
27 | List {
28 | ForEach(drafts) { draft in
29 | HStack {
30 | VStack(alignment: .leading) {
31 | if let date = draft.date {
32 | Text(date, style: .date)
33 | .font(.broadcastFootnote)
34 | .foregroundColor(.secondary)
35 | }
36 |
37 | if let text = draft.text {
38 | Text(text)
39 | } else {
40 | Text("Empty Draft").foregroundColor(.secondary)
41 | }
42 | }
43 |
44 | Spacer()
45 |
46 | if let imageData = draft.media,
47 | let image = UIImage(data: imageData) {
48 | Image(uiImage: image)
49 | .resizable()
50 | .aspectRatio(contentMode: .fill)
51 | .frame(width: thumbnailSize, height: thumbnailSize)
52 | .cornerRadius(8)
53 | }
54 | }
55 | .contentShape(Rectangle())
56 | .onTapGesture {
57 | presentationMode.wrappedValue.dismiss()
58 | twitterClient.retreiveDraft(draft: draft)
59 | }
60 | }.onDelete(perform: deleteDrafts)
61 | }
62 | }
63 | }
64 | .toolbar {
65 | EditButton()
66 |
67 | Button(action: { presentationMode.wrappedValue.dismiss() }) {
68 | Text("Close")
69 | }
70 | }
71 | .font(.broadcastBody)
72 | .navigationTitle("Drafts")
73 | }.accentColor(themeHelper.color)
74 | }
75 |
76 | func deleteDrafts(at offsets: IndexSet) {
77 | for index in offsets {
78 | let draft = drafts[index]
79 | managedObjectContext.delete(draft)
80 | }
81 |
82 | PersistanceController.shared.save()
83 | }
84 | }
85 |
86 | struct DraftsListView_Previews: PreviewProvider {
87 | static var previews: some View {
88 | DraftsListView()
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/Broadcast/Helper Views/ActionBarView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ActionBarView.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 03/07/2021.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ActionBarView: View {
11 | @ScaledMetric var barHeight: CGFloat = 80
12 | @EnvironmentObject var twitterClient: TwitterClient
13 | @Binding var replying: Bool
14 |
15 | @State private var photoPickerIsPresented = false
16 |
17 | var body: some View {
18 | publishingActions
19 | .disabled(twitterClient.state == .busy)
20 | .sheet(isPresented: $photoPickerIsPresented) {
21 | ImagePicker(chosenImage: $twitterClient.draft.media)
22 | }
23 | }
24 |
25 | var publishingActions: some View {
26 | HStack {
27 | if let replyId = twitterClient.lastTweet?.id {
28 | Button(action: {
29 | if replying {
30 | twitterClient.sendReply(to: replyId)
31 | } else {
32 | withAnimation(.springAnimation) { replying = true }
33 | }
34 | }) {
35 | Label("Send Reply", systemImage: "arrowshape.turn.up.left.fill")
36 | .font(.broadcastHeadline)
37 | .labelStyle(
38 | BroadcastLabelStyle(
39 | appearance: !replying ? .iconOnly : .normal,
40 | accessibilityLabel: "Send Reply"
41 | )
42 | )
43 | }
44 | .buttonStyle(
45 | BroadcastButtonStyle(
46 | prominence: replying ? .primary : .secondary,
47 | isFullWidth: replying,
48 | isLoading: twitterClient.state == .busy && replying
49 | )
50 | )
51 | .disabled(replying && !twitterClient.draft.isValid)
52 | }
53 |
54 | Button(action: {
55 | if !replying {
56 | twitterClient.sendTweet()
57 | } else {
58 | withAnimation(.springAnimation) { replying = false }
59 | }
60 | }) {
61 | Label("Send Tweet", systemImage: "paperplane.fill")
62 | .font(.broadcastHeadline)
63 | .labelStyle(
64 | BroadcastLabelStyle(
65 | appearance: replying ? .iconOnly : .normal,
66 | accessibilityLabel: "Send Tweet"
67 | )
68 | )
69 | }
70 | .buttonStyle(
71 | BroadcastButtonStyle(
72 | prominence: !replying ? .primary : .secondary,
73 | isFullWidth: !replying,
74 | isLoading: twitterClient.state == .busy && !replying
75 | )
76 | )
77 | .disabled(!replying && !twitterClient.draft.isValid)
78 | .accessibilityIdentifier("sendTweetButton")
79 |
80 | Button(action: {
81 | photoPickerIsPresented.toggle()
82 | UIApplication.shared.endEditing()
83 | }) {
84 | Label("Add Media", systemImage: "photo.on.rectangle.angled")
85 | .labelStyle(IconOnlyLabelStyle())
86 | }
87 | .buttonStyle(BroadcastButtonStyle(prominence: .tertiary, isFullWidth: false))
88 | .accessibilityIdentifier("imagePickerButton")
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/Broadcast/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "app-icon-20@2x.png",
5 | "idiom" : "iphone",
6 | "scale" : "2x",
7 | "size" : "20x20"
8 | },
9 | {
10 | "filename" : "app-icon-20@3x.png",
11 | "idiom" : "iphone",
12 | "scale" : "3x",
13 | "size" : "20x20"
14 | },
15 | {
16 | "filename" : "app-icon-29@1x.png",
17 | "idiom" : "iphone",
18 | "scale" : "1x",
19 | "size" : "29x29"
20 | },
21 | {
22 | "filename" : "app-icon-29@2x.png",
23 | "idiom" : "iphone",
24 | "scale" : "2x",
25 | "size" : "29x29"
26 | },
27 | {
28 | "filename" : "app-icon-29@3x.png",
29 | "idiom" : "iphone",
30 | "scale" : "3x",
31 | "size" : "29x29"
32 | },
33 | {
34 | "filename" : "app-icon-40@2x.png",
35 | "idiom" : "iphone",
36 | "scale" : "2x",
37 | "size" : "40x40"
38 | },
39 | {
40 | "filename" : "app-icon-40@3x.png",
41 | "idiom" : "iphone",
42 | "scale" : "3x",
43 | "size" : "40x40"
44 | },
45 | {
46 | "filename" : "app-icon-60@2x.png",
47 | "idiom" : "iphone",
48 | "scale" : "2x",
49 | "size" : "60x60"
50 | },
51 | {
52 | "filename" : "app-icon-60@3x.png",
53 | "idiom" : "iphone",
54 | "scale" : "3x",
55 | "size" : "60x60"
56 | },
57 | {
58 | "filename" : "app-icon-20@1x.png",
59 | "idiom" : "ipad",
60 | "scale" : "1x",
61 | "size" : "20x20"
62 | },
63 | {
64 | "filename" : "app-icon-20@2x-1.png",
65 | "idiom" : "ipad",
66 | "scale" : "2x",
67 | "size" : "20x20"
68 | },
69 | {
70 | "filename" : "app-icon-29@1x-1.png",
71 | "idiom" : "ipad",
72 | "scale" : "1x",
73 | "size" : "29x29"
74 | },
75 | {
76 | "filename" : "app-icon-29@2x-1.png",
77 | "idiom" : "ipad",
78 | "scale" : "2x",
79 | "size" : "29x29"
80 | },
81 | {
82 | "filename" : "app-icon-40@1x.png",
83 | "idiom" : "ipad",
84 | "scale" : "1x",
85 | "size" : "40x40"
86 | },
87 | {
88 | "filename" : "app-icon-40@2x-1.png",
89 | "idiom" : "ipad",
90 | "scale" : "2x",
91 | "size" : "40x40"
92 | },
93 | {
94 | "filename" : "app-icon-76@1x.png",
95 | "idiom" : "ipad",
96 | "scale" : "1x",
97 | "size" : "76x76"
98 | },
99 | {
100 | "filename" : "app-icon-76@2x.png",
101 | "idiom" : "ipad",
102 | "scale" : "2x",
103 | "size" : "76x76"
104 | },
105 | {
106 | "filename" : "app-icon-83.5@2x.png",
107 | "idiom" : "ipad",
108 | "scale" : "2x",
109 | "size" : "83.5x83.5"
110 | },
111 | {
112 | "filename" : "app-icon-1024@1x.png",
113 | "idiom" : "ios-marketing",
114 | "scale" : "1x",
115 | "size" : "1024x1024"
116 | }
117 | ],
118 | "info" : {
119 | "author" : "xcode",
120 | "version" : 1
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/BroadcastUITests/BroadcastUITests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BroadcastUITests.swift
3 | // BroadcastUITests
4 | //
5 | // Created by Daniel Eden on 15/08/2021.
6 | //
7 |
8 | import XCTest
9 |
10 | extension XCUIApplication {
11 | static func initWithLaunchParameters() -> XCUIApplication {
12 | let instance = XCUIApplication()
13 | instance.launchArguments = ["isTestEnvironment"]
14 |
15 | return instance
16 | }
17 | }
18 |
19 | class BroadcastUITests: XCTestCase {
20 | override func setUpWithError() throws {
21 | continueAfterFailure = false
22 | }
23 |
24 | func testKeyElementsExist() throws {
25 | let app = XCUIApplication.initWithLaunchParameters()
26 | app.launch()
27 |
28 | let sendTweet = app.buttons["sendTweetButton"]
29 | XCTAssert(sendTweet.exists)
30 | XCTAssert(!sendTweet.isEnabled)
31 |
32 | let composer = app.textViews["tweetComposer"]
33 | XCTAssert(composer.exists)
34 | }
35 |
36 | func testSendTweetButtonEnabledOnValidText() throws {
37 | let app = XCUIApplication.initWithLaunchParameters()
38 | app.launch()
39 |
40 | let sendTweet = app.buttons["sendTweetButton"]
41 | let composer = app.textViews["tweetComposer"]
42 |
43 | composer.tap()
44 | UIPasteboard.general.string = "Hello, world"
45 | composer.press(forDuration: 1.1)
46 | app.menuItems["Paste"].tap()
47 | _ = XCTWaiter.wait(for: [expectation(description: "Wait for .5 seconds")], timeout: 0.5)
48 |
49 | XCTAssertEqual(composer.value as? String, "Hello, world")
50 | XCTAssert(sendTweet.isEnabled)
51 | }
52 |
53 | func testSendTweetButtonDisabledOnEmptyText() throws {
54 | let app = XCUIApplication.initWithLaunchParameters()
55 | app.launch()
56 |
57 | let sendTweet = app.buttons["sendTweetButton"]
58 | let composer = app.textViews["tweetComposer"]
59 |
60 | composer.tap()
61 | UIPasteboard.general.string = " "
62 | composer.press(forDuration: 1.1)
63 | app.menuItems["Paste"].tap()
64 | _ = XCTWaiter.wait(for: [expectation(description: "Wait for .5 seconds")], timeout: 0.5)
65 |
66 | XCTAssertEqual(composer.value as? String, " ")
67 | XCTAssert(!sendTweet.isEnabled)
68 | }
69 |
70 | func testSendTweetButtonDisabledOnLargeText() throws {
71 | let app = XCUIApplication.initWithLaunchParameters()
72 | app.launch()
73 |
74 | let sendTweet = app.buttons["sendTweetButton"]
75 | let composer = app.textViews["tweetComposer"]
76 |
77 | composer.tap()
78 | UIPasteboard.general.string = String(repeating: "a", count: 281)
79 | composer.press(forDuration: 1.1)
80 | app.menuItems["Paste"].tap()
81 | _ = XCTWaiter.wait(for: [expectation(description: "Wait for .5 seconds")], timeout: 0.5)
82 |
83 | XCTAssert(!sendTweet.isEnabled)
84 | }
85 |
86 | func testLogout() throws {
87 | let app = XCUIApplication.initWithLaunchParameters()
88 | app.launch()
89 |
90 | let profilePhoto = app.images["profilePhotoButton"]
91 | profilePhoto.tap()
92 |
93 | let logoutHandle = app.descendants(matching: .any).matching(identifier: "logoutProfilePhotoHandle").firstMatch
94 | let logoutTarget = app.images["logoutTarget"]
95 | logoutHandle.press(forDuration: 0.1, thenDragTo: logoutTarget)
96 |
97 | _ = XCTWaiter.wait(for: [expectation(description: "Wait for .5 seconds")], timeout: 0.5)
98 |
99 | let loginButton = app.buttons["loginButton"]
100 | XCTAssert(loginButton.exists)
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/Broadcast.xcodeproj/xcshareddata/xcschemes/Broadcast.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
34 |
35 |
36 |
37 |
39 |
45 |
46 |
47 |
48 |
49 |
59 |
61 |
67 |
68 |
69 |
70 |
76 |
78 |
84 |
85 |
86 |
87 |
89 |
90 |
93 |
94 |
95 |
--------------------------------------------------------------------------------
/Broadcast/SignOutView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SignOutView.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 27/06/2021.
6 | //
7 |
8 | import SwiftUI
9 | import CoreHaptics
10 |
11 | struct SignOutView: View {
12 | @Environment(\.colorScheme) var colorScheme
13 | @Environment(\.presentationMode) var presentationMode
14 | @EnvironmentObject var twitterClient: TwitterClient
15 | @EnvironmentObject var themeHelper: ThemeHelper
16 |
17 | @State private var offset = CGSize.zero
18 | @State private var willDelete = false
19 |
20 | @ScaledMetric var size: CGFloat = 88
21 |
22 | var labelOpacity: Double {
23 | Double(1 - abs(offset.height) / 200)
24 | }
25 |
26 | @State private var animating = false
27 |
28 | var body: some View {
29 | VStack {
30 | Spacer()
31 | if let screenName = twitterClient.user?.screenName {
32 | Label("Drag to sign out @\(screenName)", systemImage: "arrow.down.circle")
33 | .font(.broadcastBody.bold())
34 | .foregroundColor(.secondary)
35 | .padding()
36 | .opacity(labelOpacity)
37 | }
38 |
39 | VStack {
40 | Group {
41 | if let profileImageURL = twitterClient.user?.profileImageURL {
42 | RemoteImage(url: profileImageURL, placeholder: { ProgressView() })
43 | .aspectRatio(contentMode: .fill)
44 | .clipShape(Circle())
45 | } else {
46 | Image(systemName: "person.crop.circle.fill")
47 | .resizable()
48 | }
49 | }
50 | .shadow(
51 | color: (willDelete || colorScheme == .dark) ? .black.opacity(0.2) : .accentColor,
52 | radius: 8, x: 0, y: 4
53 | )
54 | .foregroundColor(.white)
55 | .padding(8)
56 | .frame(width: size, height: size)
57 | .background(willDelete
58 | ? Color(.secondarySystemBackground)
59 | : .accentColor.opacity(colorScheme == .dark ? 0.9 : 0.5)
60 | )
61 | .clipShape(Circle())
62 | .onTapGesture {
63 | themeHelper.rotateTheme()
64 | Haptics.shared.sendStandardFeedback(feedbackType: .success)
65 | }
66 | .offset(offset)
67 | .highPriorityGesture(
68 | DragGesture()
69 | .onChanged { gesture in
70 | withAnimation { self.offset.height = min(gesture.translation.height, 200 + size) }
71 |
72 | withAnimation(.interactiveSpring()) { willDelete = self.offset.height >= 200 }
73 | }
74 |
75 | .onEnded { _ in
76 | if self.offset.height >= 200 {
77 | startSignOut()
78 | } else {
79 | withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.6, blendDuration: 0.4)) {
80 | self.offset = .zero
81 | willDelete = false
82 | }
83 | }
84 | }
85 | )
86 | .accessibilityIdentifier("logoutProfilePhotoHandle")
87 |
88 | Color.clear.frame(height: 180)
89 |
90 | Image(systemName: "trash")
91 | .resizable()
92 | .aspectRatio(contentMode: .fit)
93 | .padding(size * 0.3)
94 | .frame(width: size, height: size)
95 | .background(willDelete ? Color(.systemRed) : Color(.secondarySystemBackground))
96 | .foregroundColor(willDelete ? .white : .primary)
97 | .clipShape(Circle())
98 | .accessibilityIdentifier("logoutTarget")
99 | }
100 | Spacer()
101 |
102 | Button(action: { presentationMode.wrappedValue.dismiss() }) {
103 | Text("Close")
104 | }.buttonStyle(BroadcastButtonStyle(prominence: .tertiary))
105 | .opacity(labelOpacity)
106 | }
107 | .padding()
108 | .onChange(of: willDelete) { willDelete in
109 | let v: Float = willDelete ? 1 : 0.3
110 | Haptics.shared.sendFeedback(intensity: v, sharpness: v)
111 | }
112 | .accentColor(themeHelper.color)
113 | }
114 |
115 | func startSignOut() {
116 | twitterClient.signOut()
117 | presentationMode.wrappedValue.dismiss()
118 | }
119 | }
120 |
121 | struct SignOutView_Previews: PreviewProvider {
122 | static var previews: some View {
123 | SignOutView()
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/Broadcast/ContentView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentView.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 26/06/2021.
6 | //
7 |
8 | import SwiftUI
9 | import Introspect
10 | import TwitterText
11 |
12 | struct ContentView: View {
13 | @ScaledMetric private var captionSize: CGFloat = 14
14 | @ScaledMetric private var bottomPadding: CGFloat = 80
15 | @ScaledMetric private var replyBoxLimit: CGFloat = 96
16 |
17 | @EnvironmentObject var twitterClient: TwitterClient
18 |
19 | @State private var photoPickerIsPresented = false
20 | @State private var signOutScreenIsPresented = false
21 | @State private var repliesSheetIsPresented = false
22 |
23 | @State private var sendingTweet = false
24 | @State private var replying = false
25 | @State private var replyBoxHeight: CGFloat = 0
26 |
27 | private var imageHeightCompensation: CGFloat {
28 | (twitterClient.draft.media == nil ? 0 : bottomPadding) +
29 | (replying ? min(replyBoxHeight, replyBoxLimit) : 0)
30 | }
31 |
32 | var body: some View {
33 | GeometryReader { geom in
34 | ZStack(alignment: .bottom) {
35 | ScrollView {
36 | VStack {
37 | if replying, let lastTweet = twitterClient.lastTweet {
38 | LastTweetReplyView(lastTweet: lastTweet)
39 | .background(GeometryReader { geometry in
40 | Color.clear.preference(
41 | key: ReplyBoxSizePreferenceKey.self,
42 | value: geometry.size.height
43 | )
44 | })
45 | .onTapGesture {
46 | repliesSheetIsPresented = true
47 | }
48 | }
49 |
50 | if case .error(let errorMessage) = twitterClient.state {
51 | Text(errorMessage ?? "Some weird kind of error occurred; @_dte is probably to blame since he made this app.")
52 | .font(.broadcastFootnote.weight(.semibold))
53 | .foregroundColor(Color(.systemRed))
54 | .padding()
55 | .frame(maxWidth: .infinity)
56 | .background(Color(.systemRed).opacity(0.2))
57 | .cornerRadius(captionSize)
58 | .onTapGesture {
59 | withAnimation {
60 | twitterClient.state = .idle
61 | }
62 | }
63 | }
64 |
65 | if $twitterClient.user.wrappedValue != nil {
66 | ComposerView(signOutScreenIsPresented: $signOutScreenIsPresented)
67 | .frame(
68 | height: geom.size.height - (bottomPadding + (captionSize * 2)) - imageHeightCompensation,
69 | alignment: .topLeading
70 | )
71 |
72 | AttachmentThumbnail(image: $twitterClient.draft.media)
73 | } else {
74 | WelcomeView()
75 | }
76 | }
77 | .padding()
78 | .padding(.bottom, bottomPadding)
79 | .frame(maxWidth: geom.size.width)
80 | }
81 |
82 | VStack {
83 | if twitterClient.user != nil {
84 | ActionBarView(replying: $replying)
85 | } else {
86 | Button(action: { twitterClient.signIn() }) {
87 | Label("Sign In With Twitter", image: "twitter.fill")
88 | .font(.broadcastHeadline)
89 | }
90 | .buttonStyle(BroadcastButtonStyle())
91 | .accessibilityIdentifier("loginButton")
92 | }
93 | }
94 | .padding()
95 | .animation(.springAnimation)
96 | .background(
97 | VisualEffectView(effect: UIBlurEffect(style: .regular))
98 | .ignoresSafeArea()
99 | .opacity(twitterClient.user == nil ? 0 : 1)
100 | )
101 | .gesture(DragGesture().onEnded({ _ in UIApplication.shared.endEditing() }))
102 | }
103 | .sheet(isPresented: $signOutScreenIsPresented) {
104 | SignOutView()
105 | }
106 | .sheet(isPresented: $repliesSheetIsPresented) {
107 | RepliesListView(tweet: twitterClient.lastTweet)
108 | .accentColor(ThemeHelper.shared.color)
109 | .font(.broadcastBody)
110 | }
111 | .onAppear {
112 | UITextView.appearance().backgroundColor = .clear
113 | }
114 | .onChange(of: replying) { _ in
115 | twitterClient.revalidateAccount()
116 | }
117 | .onPreferenceChange(ReplyBoxSizePreferenceKey.self) { newValue in
118 | withAnimation(.easeInOut(duration: 0.1)) { replyBoxHeight = newValue }
119 | }
120 | }
121 | }
122 | }
123 |
124 | extension ContentView {
125 | struct ReplyBoxSizePreferenceKey: PreferenceKey {
126 | static let defaultValue: CGFloat = 0
127 |
128 | static func reduce(value: inout CGFloat,
129 | nextValue: () -> CGFloat) {
130 | value = max(value, nextValue())
131 | }
132 | }
133 | }
134 |
135 | struct ContentView_Previews: PreviewProvider {
136 | static var previews: some View {
137 | ContentView()
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/Broadcast/Helper Views/ComposerView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ComposerView.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 30/06/2021.
6 | //
7 |
8 | import SwiftUI
9 | import TwitterText
10 |
11 | fileprivate let placeholderCandidates: [String] = [
12 | "Wh—what’s going on?",
13 | "Oh hey uh, what’s up",
14 | "What’s Twitter?",
15 | "Tweet away, sweet child",
16 | "Say something nice",
17 | "Cowabunga, dude",
18 | "You’re doing a great job"
19 | ]
20 |
21 | struct ComposerView: View {
22 | let debouncer = Debouncer(timeInterval: 0.3)
23 | @Binding var signOutScreenIsPresented: Bool
24 |
25 | @EnvironmentObject var twitterClient: TwitterClient
26 | @ScaledMetric private var minComposerHeight: CGFloat = 120
27 | @ScaledMetric private var captionSize: CGFloat = 14
28 | @ScaledMetric private var leftOffset: CGFloat = 4
29 | @ScaledMetric private var verticalPadding: CGFloat = 6
30 |
31 | @State private var placeholder: String = placeholderCandidates.randomElement()
32 | @State private var draftListVisible = false
33 |
34 | private let mentioningRegex = NSRegularExpression("@[a-z0-9_]+$", options: .caseInsensitive)
35 |
36 | private var tweetText: String {
37 | twitterClient.draft.text ?? ""
38 | }
39 |
40 | private var mentionString: String? {
41 | mentioningRegex.firstMatchAsString(tweetText)
42 | }
43 |
44 | private var charCount: Int {
45 | TwitterText.tweetLength(text: tweetText)
46 | }
47 |
48 | private var mentionCandidates: [TwitterClient.User]? {
49 | twitterClient.userSearchResults
50 | }
51 |
52 | private var tweetLengthWarning: String {
53 | switch charCount {
54 | case 380...479:
55 | return " (ok c’mon dude)"
56 | case 480...679:
57 | return " (this isn’t the notes app)"
58 | case 680...1023:
59 | return " (the app probably looks pretty bad right now, huh)"
60 | case 1024...2047:
61 | return " (I bet you’re wondering when this will stop)"
62 | case 2048...19999:
63 | return " (you’re really gonna keep going?)"
64 | case 20000...30000:
65 | return " (I’m impressed, really)"
66 | case 30000...42348:
67 | return " (almost there)"
68 | case 42349:
69 | return " (nice)"
70 | case 42350...Int.max:
71 | return " (fin)"
72 | default:
73 | return ""
74 | }
75 | }
76 |
77 | var body: some View {
78 | ZStack(alignment: .bottom) {
79 | VStack(alignment: .trailing) {
80 | HStack(alignment: .top) {
81 | if let profileImageURL = twitterClient.user?.profileImageURL {
82 | RemoteImage(url: profileImageURL, placeholder: { ProgressView() })
83 | .aspectRatio(contentMode: .fill)
84 | .frame(width: 36, height: 36)
85 | .cornerRadius(36)
86 | .onTapGesture {
87 | signOutScreenIsPresented = true
88 | UIApplication.shared.endEditing()
89 | }
90 | .accessibilityIdentifier("profilePhotoButton")
91 | }
92 |
93 | ZStack(alignment: .topLeading) {
94 | Text(tweetText.isEmpty ? placeholder : tweetText)
95 | .padding(.leading, leftOffset)
96 | .padding(.vertical, verticalPadding)
97 | .foregroundColor(Color(.placeholderText))
98 | .opacity(tweetText.isEmpty ? 1 : 0)
99 | .accessibility(hidden: true)
100 |
101 | if #available(iOS 16.0, *) {
102 | TextEditor(text: Binding($twitterClient.draft.text, replacingNilWith: ""))
103 | .foregroundColor(Color(.label))
104 | .multilineTextAlignment(.leading)
105 | .keyboardType(.twitter)
106 | .padding(.top, (verticalPadding / 3) * -1)
107 | .accessibilityIdentifier("tweetComposer")
108 | .scrollContentBackground(.hidden)
109 | } else {
110 | TextEditor(text: Binding($twitterClient.draft.text, replacingNilWith: ""))
111 | .foregroundColor(Color(.label))
112 | .multilineTextAlignment(.leading)
113 | .keyboardType(.twitter)
114 | .padding(.top, (verticalPadding / 3) * -1)
115 | .accessibilityIdentifier("tweetComposer")
116 | }
117 | }
118 | .font(.broadcastTitle3)
119 | }.transition(.scale)
120 |
121 | Divider()
122 |
123 | HStack(alignment: .top) {
124 | Menu {
125 | Button(action: { twitterClient.saveDraft() }) {
126 | Label("Save Draft", systemImage: "square.and.pencil")
127 | }.disabled(!twitterClient.draft.isValid)
128 |
129 | Button(action: { draftListVisible = true }) {
130 | Label("View Drafts", systemImage: "doc.on.doc")
131 | }
132 | } label: {
133 | Label("Drafts", systemImage: "doc.on.doc")
134 | .font(.broadcastFootnote)
135 | }
136 |
137 | Spacer()
138 |
139 | Text("\(280 - charCount)\(tweetLengthWarning)")
140 | .foregroundColor(charCount > 200 ? charCount >= 280 ? Color(.systemRed) : Color(.systemOrange) : .secondary)
141 | .font(.system(size: min(captionSize * max(CGFloat(charCount) / 280, 1), 28), weight: .bold, design: .rounded))
142 | .multilineTextAlignment(.trailing)
143 | }
144 | }
145 | .disabled(twitterClient.state == .busy)
146 | .padding()
147 | .background(Color(.tertiarySystemGroupedBackground))
148 | .onShake {
149 | rotatePlaceholder()
150 | Haptics.shared.sendStandardFeedback(feedbackType: .success)
151 | }
152 | .onChange(of: twitterClient.draft.isValid) { isValid in
153 | if !isValid && charCount > 280 {
154 | Haptics.shared.sendStandardFeedback(feedbackType: .warning)
155 | }
156 | }
157 | .sheet(isPresented: $draftListVisible) {
158 | DraftsListView()
159 | .environmentObject(ThemeHelper.shared)
160 | .environment(\.managedObjectContext, PersistanceController.shared.context)
161 | }
162 | .onChange(of: mentionString) { value in
163 | if let screenName = value {
164 | debouncer.renewInterval()
165 | debouncer.handler = {
166 | self.twitterClient.searchScreenNames(screenName)
167 | }
168 | }
169 | }
170 |
171 | if let users = mentionCandidates,
172 | !users.isEmpty,
173 | let mentionString = mentionString,
174 | !mentionString.isEmpty {
175 | MentionBar(users: users) { user in
176 | completeMention(user)
177 | }
178 | }
179 | }.cornerRadius(captionSize)
180 | }
181 |
182 | func completeMention(_ user: TwitterClient.User) {
183 | let textToComplete = mentioningRegex.firstMatchAsString(tweetText) ?? ""
184 | let draft = twitterClient.draft.text?.replacingOccurrences(of: textToComplete, with: "@\(user.screenName) ")
185 | twitterClient.draft.text = draft
186 | }
187 |
188 | func rotatePlaceholder() {
189 | var newPlaceholder = placeholder
190 |
191 | while newPlaceholder == placeholder {
192 | newPlaceholder = placeholderCandidates.randomElement()
193 | }
194 |
195 | placeholder = newPlaceholder
196 | }
197 | }
198 |
199 | struct ComposerView_Previews: PreviewProvider {
200 | static var previews: some View {
201 | ComposerView(signOutScreenIsPresented: .constant(false))
202 | }
203 | }
204 |
--------------------------------------------------------------------------------
/Broadcast/Helpers/TwitterClient.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TwitterClient.swift
3 | // Broadcast
4 | //
5 | // Created by Daniel Eden on 30/06/2021.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 | import Swifter
11 | import TwitterText
12 | import UIKit
13 | import AuthenticationServices
14 | import SwiftKeychainWrapper
15 | import SwiftUI
16 |
17 | let typeaheadToken = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
18 |
19 | class TwitterClient: NSObject, ObservableObject {
20 | let draftsStore = PersistanceController.shared
21 | @Published var user: User?
22 | @Published var draft = Tweet()
23 | @Published var state: State = .idle
24 | @Published var lastTweet: Tweet?
25 |
26 | private var client = Swifter.init(consumerKey: ClientCredentials.apiKey, consumerSecret: ClientCredentials.apiSecret)
27 |
28 | override init() {
29 | super.init()
30 |
31 | if let storedCredentials = retreiveCredentials() {
32 | self.client.client.credential = .init(accessToken: storedCredentials)
33 | if let userId = storedCredentials.userID,
34 | let screenName = storedCredentials.screenName {
35 | self.user = .init(id: userId, screenName: screenName)
36 | self.revalidateAccount()
37 | }
38 | }
39 | }
40 |
41 | func signIn() {
42 | DispatchQueue.main.async { self.state = .busy }
43 | client.authorize(withProvider: self, callbackURL: ClientCredentials.callbackURL) { credentials, response in
44 | guard let credentials = credentials,
45 | let id = credentials.userID,
46 | let screenName = credentials.screenName else {
47 | self.state = .error("Yikes, something when wrong when trying to sign in")
48 | return
49 | }
50 |
51 | self.storeCredentials(credentials: credentials)
52 |
53 | DispatchQueue.main.async {
54 | self.state = .idle
55 | self.user = User(id: id, screenName: screenName)
56 | self.revalidateAccount()
57 | }
58 | }
59 | }
60 |
61 | func revalidateAccount() {
62 | guard let userId = user?.id else {
63 | self.signOut()
64 | return
65 | }
66 |
67 | client.showUser(.id(userId), tweetMode: .extended) { json in
68 | /** If the `showUser` call was successful, we can reuse the result to update the user’s profile photo */
69 | guard let urlString = json["profile_image_url_https"].string else {
70 | return
71 | }
72 |
73 | withAnimation {
74 | self.user?.originalProfileImageURL = URL(string: urlString.replacingOccurrences(of: "_normal", with: ""))
75 | }
76 |
77 | self.updateLastTweet(from: json["status"])
78 | } failure: { error in
79 | self.signOut()
80 | self.updateState(.error("Yikes; there was a problem signing in to Twitter. You’ll have to try signing in again."))
81 | }
82 | }
83 |
84 | func signOut() {
85 | self.user = nil
86 | self.draft = .init()
87 | self.lastTweet = nil
88 | KeychainWrapper.standard.remove(forKey: "broadcast-credentials")
89 | }
90 |
91 | func storeCredentials(credentials: Credential.OAuthAccessToken) {
92 | guard let data = credentials.data else {
93 | return
94 | }
95 |
96 | KeychainWrapper.standard.set(data, forKey: "broadcast-credentials")
97 | }
98 |
99 | func retreiveCredentials() -> Credential.OAuthAccessToken? {
100 | if isTestEnvironment {
101 | return .init(queryString: ClientCredentials.__authQueryString)
102 | }
103 |
104 | guard let data = KeychainWrapper.standard.data(forKey: "broadcast-credentials") else {
105 | return nil
106 | }
107 |
108 | return .init(from: data)
109 | }
110 |
111 | private func sendTweetCallback(response: JSON? = nil, error: Error? = nil) {
112 | if let json = response {
113 | self.updateLastTweet(from: json)
114 | self.updateState(.idle)
115 | self.draft = .init()
116 | Haptics.shared.sendStandardFeedback(feedbackType: .success)
117 | } else if let error = error {
118 | print(error.localizedDescription)
119 |
120 | if draft.media != nil {
121 | self.updateState(.genericTextAndMediaError)
122 | } else {
123 | self.updateState(.genericTextError)
124 | }
125 |
126 | Haptics.shared.sendStandardFeedback(feedbackType: .error)
127 | }
128 | }
129 |
130 | func sendTweet() {
131 | updateState(.busy)
132 |
133 | if let mediaData = draft.media?.jpegData(compressionQuality: 0.8) {
134 | client.postTweet(status: draft.text ?? "", media: mediaData) { json in
135 | self.sendTweetCallback(response: json)
136 | } failure: { error in
137 | print(error.localizedDescription)
138 | self.sendTweetCallback(error: error)
139 | }
140 | } else if let status = draft.text {
141 | client.postTweet(status: status) { json in
142 | self.sendTweetCallback(response: json)
143 | } failure: { error in
144 | print(error.localizedDescription)
145 | self.sendTweetCallback(error: error)
146 | }
147 | }
148 | }
149 |
150 | func sendReply(to id: String) {
151 | updateState(.busy)
152 |
153 | if let mediaData = draft.media?.jpegData(compressionQuality: 0.8) {
154 | client.postTweet(status: draft.text ?? "", media: mediaData, inReplyToStatusID: id) { json in
155 | self.sendTweetCallback(response: json)
156 | } failure: { error in
157 | self.sendTweetCallback(error: error)
158 | }
159 | } else if let status = draft.text {
160 | client.postTweet(status: status, inReplyToStatusID: id) { json in
161 | self.sendTweetCallback(response: json)
162 | Haptics.shared.sendStandardFeedback(feedbackType: .success)
163 | } failure: { error in
164 | self.sendTweetCallback(error: error)
165 | }
166 | }
167 | }
168 |
169 | private func updateLastTweet(from json: JSON) {
170 | guard let id = json["id_str"].string else { return }
171 | var lastTweet = Tweet(id: id)
172 | lastTweet.text = json["full_text"].string ?? json["text"].string
173 | lastTweet.likes = json["favorite_count"].integer
174 | lastTweet.retweets = json["retweet_count"].integer
175 | lastTweet.numericId = json["id"].integer
176 |
177 | if !isTestEnvironment {
178 | self.getReplies(for: lastTweet) { replies in
179 | lastTweet.replies = replies
180 | self.lastTweet = lastTweet
181 | }
182 | }
183 | }
184 |
185 | /// Asynchronously update client state on the main thread
186 | /// - Parameter newState: The new state for the client
187 | private func updateState(_ newState: State) {
188 | DispatchQueue.main.async {
189 | self.state = newState
190 | }
191 | }
192 |
193 | @Published var userSearchResults: [User]?
194 | private var userSearchCancellables = [AnyCancellable]()
195 | func searchScreenNames(_ screenName: String) {
196 | let url = URL(string: "https://twitter.com/i/search/typeahead.json?count=10&q=%23\(screenName)&result_type=users")!
197 |
198 | var headers = [
199 | "Authorization": typeaheadToken
200 | ]
201 |
202 | if let userId = user?.id,
203 | let token = client.client.credential?.accessToken?.key {
204 | headers["Cookie"] = "twid=u%3D\(userId);auth_token=\(token)"
205 | }
206 |
207 | var request = URLRequest(url: url)
208 | request.allHTTPHeaderFields = headers
209 | request.httpShouldHandleCookies = true
210 |
211 | URLSession.shared.dataTaskPublisher(for: request)
212 | .tryMap() { element -> Data in
213 | guard let httpResponse = element.response as? HTTPURLResponse,
214 | httpResponse.statusCode == 200 else {
215 | throw URLError(.badServerResponse)
216 | }
217 | return element.data
218 | }
219 | .decode(type: TypeaheadResponse.self, decoder: JSONDecoder())
220 | .sink { completion in
221 | switch completion {
222 | case .failure(let error):
223 | print(error.localizedDescription)
224 | default:
225 | return
226 | }
227 | } receiveValue: { result in
228 | DispatchQueue.main.async {
229 | self.userSearchResults = result.users
230 | }
231 | }.store(in: &userSearchCancellables)
232 | }
233 |
234 | /// Asynchronously provides up to 200 replies for the given tweet. This method works by fetching the
235 | /// most recent 200 @mentions for the user and filters the result to those replying to the provided tweet.
236 | /// - Parameters:
237 | /// - tweet: The tweet to fetch replies for
238 | /// - completion: A callback for handling the replies
239 | private func getReplies(for tweet: Tweet, completion: @escaping ([Tweet]) -> Void = { _ in }) {
240 | let formatter = DateFormatter()
241 | formatter.dateFormat = "EE MMM dd HH:mm:ss Z yyyy"
242 |
243 | guard let tweetId = tweet.id else { return }
244 |
245 | client.getMentionsTimelineTweets(count: 200, tweetMode: .extended) { json in
246 | guard let repliesResult = json.array else { return }
247 | let repliesToThisTweet: [Tweet?] = repliesResult.filter { json in
248 | guard let replyId = json["in_reply_to_status_id"].integer else { return false }
249 | return replyId == tweet.numericId
250 | }.map { json in
251 | guard let id = json["id_str"].string,
252 | let text = json["full_text"].string,
253 | let dateString = json["created_at"].string,
254 | let date = formatter.date(from: dateString) else {
255 | return nil
256 | }
257 |
258 | let user = User(from: json["user"])
259 | return Tweet(id: id, text: text, date: date, author: user)
260 | }
261 |
262 | completion(repliesToThisTweet.compactMap { $0 })
263 |
264 | } failure: { error in
265 | print("Error fetching replies for Tweet with ID \(tweetId)")
266 | print(error.localizedDescription)
267 | }
268 | }
269 | }
270 |
271 | extension TwitterClient: ASWebAuthenticationPresentationContextProviding {
272 | func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
273 | return ASPresentationAnchor()
274 | }
275 | }
276 |
277 | /* MARK: Drafts */
278 | extension TwitterClient {
279 | /// Saves the current draft to CoreData for later retrieval. This method also resets/clears the current draft.
280 | func saveDraft() {
281 | guard draft.isValid else { return }
282 | let copy = draft
283 |
284 | DispatchQueue.global(qos: .default).async {
285 | let newDraft = Draft.init(context: self.draftsStore.context)
286 | newDraft.date = Date()
287 | newDraft.text = copy.text
288 | newDraft.media = copy.media?.fixedOrientation.pngData()
289 | newDraft.id = UUID()
290 |
291 | self.draftsStore.save()
292 | }
293 |
294 | withAnimation {
295 | self.draft = .init()
296 | self.state = .idle
297 | }
298 | }
299 |
300 | /// Retrieve the specified draft from CoreData, storing it in memory and deleting it from the CoreData database
301 | /// - Parameter draft: The chosen draft for retrieval and deletion
302 | func retreiveDraft(draft: Draft) {
303 | withAnimation {
304 | self.draft = Tweet(text: draft.text)
305 |
306 | if let media = draft.media {
307 | self.draft.media = UIImage(data: media)
308 | }
309 | }
310 |
311 | let managedObjectContext = PersistanceController.shared.context
312 | managedObjectContext.delete(draft)
313 |
314 | PersistanceController.shared.save()
315 | }
316 | }
317 |
318 | // MARK: Models
319 | extension TwitterClient {
320 | enum State: Equatable {
321 | case idle, busy
322 | case error(_: String? = nil)
323 |
324 | static var genericTextError = State.error("Oh man, something went wrong sending that tweet. It might be too long.")
325 | static var genericTextAndMediaError = State.error("Oh man, something went wrong sending that tweet. Maybe it’s too long, or your chosen media is causing a problem.")
326 | }
327 |
328 | struct ClientCredentials {
329 | static private var plist: NSDictionary? {
330 | guard let filePath = Bundle.main.path(forResource: "TwitterAPI-Info", ofType: "plist") else {
331 | fatalError("Couldn't find file 'TwitterAPI-Info.plist'.")
332 | }
333 | // 2
334 | return NSDictionary(contentsOfFile: filePath)
335 | }
336 |
337 | static var apiKey: String {
338 | guard let value = plist?.object(forKey: "API_KEY") as? String else {
339 | fatalError("Couldn't find key 'API_KEY' in 'TwitterAPI-Info.plist'.")
340 | }
341 |
342 | return value
343 | }
344 |
345 | static var apiSecret: String {
346 | guard let value = plist?.object(forKey: "API_SECRET") as? String else {
347 | fatalError("Couldn't find key 'API_KEY' in 'TwitterAPI-Info.plist'.")
348 | }
349 |
350 | return value
351 | }
352 |
353 | static var __authQueryString: String {
354 | guard let value = plist?.object(forKey: "__TEST_AUTH_QUERY_STRING") as? String else {
355 | fatalError("Couldn't find key '__TEST_AUTH_QUERY_STRING' in 'TwitterAPI-Info.plist'.")
356 | }
357 |
358 | return value
359 | }
360 |
361 | static var callbackProtocol = "twitter-broadcast://"
362 | static var callbackURL: URL {
363 | URL(string: callbackProtocol)!
364 | }
365 | }
366 |
367 | struct User: Decodable {
368 | var id: String
369 | var screenName: String
370 | var name: String?
371 | var originalProfileImageURL: URL?
372 | var profileImageURL: URL? {
373 | if let urlString = originalProfileImageURL?.absoluteString.replacingOccurrences(of: "_normal", with: "_x96") {
374 | return URL(string: urlString)
375 | } else {
376 | return originalProfileImageURL
377 | }
378 | }
379 |
380 | enum CodingKeys: String, CodingKey {
381 | case screenName = "screen_name"
382 | case originalProfileImageURL = "profile_image_url_https"
383 | case id = "id_str"
384 | case name
385 | }
386 | }
387 |
388 | struct Tweet {
389 | var numericId: Int?
390 | var id: String?
391 | var text: String?
392 | var media: UIImage?
393 |
394 | var likes: Int?
395 | var retweets: Int?
396 | var replies: [Tweet]?
397 |
398 | var date: Date?
399 |
400 | var length: Int {
401 | TwitterText.tweetLength(text: text ?? "")
402 | }
403 |
404 | var isValid: Bool {
405 | if media != nil {
406 | return true
407 | }
408 |
409 | return 1...280 ~= length && !(text ?? "").isBlank
410 | }
411 |
412 | var author: User?
413 | }
414 | }
415 |
416 | extension TwitterClient.User {
417 | init(from json: JSON) {
418 | self.name = json["name"].string
419 | self.screenName = json["screen_name"].string ?? "TwitterUser"
420 | self.id = json["id_str"].string ?? ""
421 | let imageUrlString = json["profile_image_url_https"].string ?? ""
422 | self.originalProfileImageURL = URL(string: imageUrlString)
423 | }
424 | }
425 |
426 | extension Credential.OAuthAccessToken {
427 | var data: Data? {
428 | let dict = [
429 | "key": key,
430 | "secret": secret,
431 | "userId": userID,
432 | "screenName": screenName
433 | ]
434 |
435 | return try? JSONSerialization.data(withJSONObject: dict, options: [])
436 | }
437 |
438 | init?(from data: Data) {
439 | do {
440 | let dict = try JSONDecoder().decode([String: String].self, from: data)
441 | guard let key = dict["key"],
442 | let secret = dict["secret"] else {
443 | return nil
444 | }
445 |
446 | let screenName = dict["screenName"]
447 | let userId = dict["userId"]
448 |
449 | let queryString = "oauth_token=\(key)&oauth_token_secret=\(secret)&screen_name=\(screenName ?? "")&user_id=\(userId ?? "")"
450 |
451 | self.init(queryString: queryString)
452 | } catch let error {
453 | print(error.localizedDescription)
454 | return nil
455 | }
456 | }
457 | }
458 |
459 | struct TypeaheadResponse: Decodable {
460 | var num_results: Int
461 | var users: [TwitterClient.User]?
462 | }
463 |
--------------------------------------------------------------------------------
/Broadcast.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 52;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 7101073626C810AC00A713A5 /* NullStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7101073526C810AC00A713A5 /* NullStateView.swift */; };
11 | 711EF99426C959A700FD8A9F /* BroadcastUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711EF99326C959A700FD8A9F /* BroadcastUITests.swift */; };
12 | 711F3FFA268F50C800605C89 /* Animation.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711F3FF9268F50C800605C89 /* Animation.extension.swift */; };
13 | 715AAE0A26C923A1002BCEA1 /* Swifter in Frameworks */ = {isa = PBXBuildFile; productRef = 715AAE0926C923A1002BCEA1 /* Swifter */; };
14 | 715AAE0C26C9589A002BCEA1 /* TestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */; };
15 | 717041F326B6FFEA00001360 /* RepliesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717041F226B6FFEA00001360 /* RepliesListView.swift */; };
16 | 717041F526B7037300001360 /* TweetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717041F426B7037300001360 /* TweetView.swift */; };
17 | 717041F726B703A600001360 /* TwitterClient+MockTweet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717041F626B703A600001360 /* TwitterClient+MockTweet.swift */; };
18 | 71800BF026905DB3009D11A1 /* EngagementCountersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BEF26905DB3009D11A1 /* EngagementCountersView.swift */; };
19 | 71800BF226906721009D11A1 /* ActionBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BF126906721009D11A1 /* ActionBarView.swift */; };
20 | 71800BF426906BC0009D11A1 /* DraftsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BF326906BC0009D11A1 /* DraftsListView.swift */; };
21 | 71800BF8269998BF009D11A1 /* UIImage.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BF7269998BF009D11A1 /* UIImage.extension.swift */; };
22 | 71800BFD26999B1B009D11A1 /* DraftsModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 71800BFB26999B1B009D11A1 /* DraftsModel.xcdatamodeld */; };
23 | 71800BFF26999BA6009D11A1 /* PersistanceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BFE26999BA6009D11A1 /* PersistanceController.swift */; };
24 | 7188E6292687B0FE007CFD78 /* BroadcastApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6282687B0FE007CFD78 /* BroadcastApp.swift */; };
25 | 7188E62B2687B0FE007CFD78 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E62A2687B0FE007CFD78 /* ContentView.swift */; };
26 | 7188E62D2687B0FF007CFD78 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7188E62C2687B0FF007CFD78 /* Assets.xcassets */; };
27 | 7188E6302687B0FF007CFD78 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7188E62F2687B0FF007CFD78 /* Preview Assets.xcassets */; };
28 | 7188E63B2687B19D007CFD78 /* Notification.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E63A2687B19D007CFD78 /* Notification.extension.swift */; };
29 | 7188E6472687B6C2007CFD78 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6462687B6C2007CFD78 /* SafariView.swift */; };
30 | 7188E6492687C0E0007CFD78 /* BroadcastButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6482687C0E0007CFD78 /* BroadcastButtonStyle.swift */; };
31 | 7188E64B2687C44D007CFD78 /* String.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E64A2687C44D007CFD78 /* String.extension.swift */; };
32 | 7188E64D2687C6A1007CFD78 /* Binding.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E64C2687C6A1007CFD78 /* Binding.extension.swift */; };
33 | 7188E64F2687CCD0007CFD78 /* PhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E64E2687CCD0007CFD78 /* PhotoPicker.swift */; };
34 | 7188E6532687D16C007CFD78 /* AttachmentThumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6522687D16C007CFD78 /* AttachmentThumbnail.swift */; };
35 | 7188E65D26887DCD007CFD78 /* SwiftKeychainWrapper in Frameworks */ = {isa = PBXBuildFile; productRef = 7188E65C26887DCD007CFD78 /* SwiftKeychainWrapper */; };
36 | 7188E65F26889147007CFD78 /* SignOutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E65E26889147007CFD78 /* SignOutView.swift */; };
37 | 7188E6612688A01F007CFD78 /* Font.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6602688A01F007CFD78 /* Font.extension.swift */; };
38 | 7188E6632688A0FC007CFD78 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6622688A0FC007CFD78 /* WelcomeView.swift */; };
39 | 7188E6652688A436007CFD78 /* ThemeHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6642688A436007CFD78 /* ThemeHelper.swift */; };
40 | 7188E6672688B99E007CFD78 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6662688B99E007CFD78 /* VisualEffectView.swift */; };
41 | 7188E66A2688D7BA007CFD78 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 7188E6692688D7BA007CFD78 /* Introspect */; };
42 | 7188E66C2688DB44007CFD78 /* UIApplication.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E66B2688DB44007CFD78 /* UIApplication.extension.swift */; };
43 | 719087CD26891586005B96CE /* TwitterText in Frameworks */ = {isa = PBXBuildFile; productRef = 719087CC26891586005B96CE /* TwitterText */; };
44 | 719087CF26891C7F005B96CE /* Array.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 719087CE26891C7F005B96CE /* Array.extension.swift */; };
45 | 7199AE7A26B96D0D001DEB46 /* NSRegularExpression+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7926B96D0D001DEB46 /* NSRegularExpression+Convenience.swift */; };
46 | 7199AE7C26B97327001DEB46 /* UserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7B26B97327001DEB46 /* UserView.swift */; };
47 | 7199AE7E26B973B2001DEB46 /* MentionBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7D26B973B2001DEB46 /* MentionBar.swift */; };
48 | 7199AE8026B9892E001DEB46 /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7F26B9892E001DEB46 /* Debouncer.swift */; };
49 | 71A1088A268B073B007E1FFB /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A10889268B073B007E1FFB /* Haptics.swift */; };
50 | 71A6A264278C73AD00BF2387 /* TwitterAPI-Info.example.plist in Resources */ = {isa = PBXBuildFile; fileRef = 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */; };
51 | 71AA4AC6268A032400B7B577 /* RemoteImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71AA4AC5268A032400B7B577 /* RemoteImage.swift */; };
52 | 71B8290C268D0AC6002AEE72 /* TwitterClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290B268D0AC6002AEE72 /* TwitterClient.swift */; };
53 | 71B82910268F2195002AEE72 /* LastTweetReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */; };
54 | 71BBAAE9268CF1BD004048A0 /* ComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BBAAE8268CF1BD004048A0 /* ComposerView.swift */; };
55 | 71BBAAEB268CF532004048A0 /* TwitterAPI-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 71BBAAEA268CF532004048A0 /* TwitterAPI-Info.plist */; };
56 | 71E36FFB2689EED40078D956 /* ShakeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71E36FFA2689EED40078D956 /* ShakeModifier.swift */; };
57 | /* End PBXBuildFile section */
58 |
59 | /* Begin PBXContainerItemProxy section */
60 | 711EF99626C959A700FD8A9F /* PBXContainerItemProxy */ = {
61 | isa = PBXContainerItemProxy;
62 | containerPortal = 7188E61D2687B0FE007CFD78 /* Project object */;
63 | proxyType = 1;
64 | remoteGlobalIDString = 7188E6242687B0FE007CFD78;
65 | remoteInfo = Broadcast;
66 | };
67 | /* End PBXContainerItemProxy section */
68 |
69 | /* Begin PBXFileReference section */
70 | 7101073526C810AC00A713A5 /* NullStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NullStateView.swift; sourceTree = ""; };
71 | 711EF99126C959A700FD8A9F /* BroadcastUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BroadcastUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
72 | 711EF99326C959A700FD8A9F /* BroadcastUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastUITests.swift; sourceTree = ""; };
73 | 711EF99526C959A700FD8A9F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
74 | 711F3FF9268F50C800605C89 /* Animation.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Animation.extension.swift; sourceTree = ""; };
75 | 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUtils.swift; sourceTree = ""; };
76 | 717041F226B6FFEA00001360 /* RepliesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepliesListView.swift; sourceTree = ""; };
77 | 717041F426B7037300001360 /* TweetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TweetView.swift; sourceTree = ""; };
78 | 717041F626B703A600001360 /* TwitterClient+MockTweet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TwitterClient+MockTweet.swift"; sourceTree = ""; };
79 | 71800BEF26905DB3009D11A1 /* EngagementCountersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementCountersView.swift; sourceTree = ""; };
80 | 71800BF126906721009D11A1 /* ActionBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionBarView.swift; sourceTree = ""; };
81 | 71800BF326906BC0009D11A1 /* DraftsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsListView.swift; sourceTree = ""; };
82 | 71800BF7269998BF009D11A1 /* UIImage.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.extension.swift; sourceTree = ""; };
83 | 71800BFC26999B1B009D11A1 /* DraftsModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = DraftsModel.xcdatamodel; sourceTree = ""; };
84 | 71800BFE26999BA6009D11A1 /* PersistanceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistanceController.swift; sourceTree = ""; };
85 | 7188E6252687B0FE007CFD78 /* Broadcast.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Broadcast.app; sourceTree = BUILT_PRODUCTS_DIR; };
86 | 7188E6282687B0FE007CFD78 /* BroadcastApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastApp.swift; sourceTree = ""; };
87 | 7188E62A2687B0FE007CFD78 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
88 | 7188E62C2687B0FF007CFD78 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
89 | 7188E62F2687B0FF007CFD78 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
90 | 7188E6312687B0FF007CFD78 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
91 | 7188E63A2687B19D007CFD78 /* Notification.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notification.extension.swift; sourceTree = ""; };
92 | 7188E6462687B6C2007CFD78 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; };
93 | 7188E6482687C0E0007CFD78 /* BroadcastButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastButtonStyle.swift; sourceTree = ""; };
94 | 7188E64A2687C44D007CFD78 /* String.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.extension.swift; sourceTree = ""; };
95 | 7188E64C2687C6A1007CFD78 /* Binding.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Binding.extension.swift; sourceTree = ""; };
96 | 7188E64E2687CCD0007CFD78 /* PhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPicker.swift; sourceTree = ""; };
97 | 7188E6522687D16C007CFD78 /* AttachmentThumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentThumbnail.swift; sourceTree = ""; };
98 | 7188E65E26889147007CFD78 /* SignOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignOutView.swift; sourceTree = ""; };
99 | 7188E6602688A01F007CFD78 /* Font.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Font.extension.swift; sourceTree = ""; };
100 | 7188E6622688A0FC007CFD78 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; };
101 | 7188E6642688A436007CFD78 /* ThemeHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeHelper.swift; sourceTree = ""; };
102 | 7188E6662688B99E007CFD78 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; };
103 | 7188E66B2688DB44007CFD78 /* UIApplication.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.extension.swift; sourceTree = ""; };
104 | 719087CE26891C7F005B96CE /* Array.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.extension.swift; sourceTree = ""; };
105 | 7199AE7926B96D0D001DEB46 /* NSRegularExpression+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSRegularExpression+Convenience.swift"; sourceTree = ""; };
106 | 7199AE7B26B97327001DEB46 /* UserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserView.swift; sourceTree = ""; };
107 | 7199AE7D26B973B2001DEB46 /* MentionBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionBar.swift; sourceTree = ""; };
108 | 7199AE7F26B9892E001DEB46 /* Debouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debouncer.swift; sourceTree = ""; };
109 | 71A10889268B073B007E1FFB /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; };
110 | 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "TwitterAPI-Info.example.plist"; sourceTree = ""; };
111 | 71AA4AC5268A032400B7B577 /* RemoteImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImage.swift; sourceTree = ""; };
112 | 71B8290B268D0AC6002AEE72 /* TwitterClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterClient.swift; sourceTree = ""; };
113 | 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastTweetReplyView.swift; sourceTree = ""; };
114 | 71BBAAE8268CF1BD004048A0 /* ComposerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerView.swift; sourceTree = ""; };
115 | 71BBAAEA268CF532004048A0 /* TwitterAPI-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "TwitterAPI-Info.plist"; sourceTree = ""; };
116 | 71E36FFA2689EED40078D956 /* ShakeModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShakeModifier.swift; sourceTree = ""; };
117 | /* End PBXFileReference section */
118 |
119 | /* Begin PBXFrameworksBuildPhase section */
120 | 711EF98E26C959A700FD8A9F /* Frameworks */ = {
121 | isa = PBXFrameworksBuildPhase;
122 | buildActionMask = 2147483647;
123 | files = (
124 | );
125 | runOnlyForDeploymentPostprocessing = 0;
126 | };
127 | 7188E6222687B0FE007CFD78 /* Frameworks */ = {
128 | isa = PBXFrameworksBuildPhase;
129 | buildActionMask = 2147483647;
130 | files = (
131 | 7188E65D26887DCD007CFD78 /* SwiftKeychainWrapper in Frameworks */,
132 | 7188E66A2688D7BA007CFD78 /* Introspect in Frameworks */,
133 | 719087CD26891586005B96CE /* TwitterText in Frameworks */,
134 | 715AAE0A26C923A1002BCEA1 /* Swifter in Frameworks */,
135 | );
136 | runOnlyForDeploymentPostprocessing = 0;
137 | };
138 | /* End PBXFrameworksBuildPhase section */
139 |
140 | /* Begin PBXGroup section */
141 | 711EF99226C959A700FD8A9F /* BroadcastUITests */ = {
142 | isa = PBXGroup;
143 | children = (
144 | 711EF99326C959A700FD8A9F /* BroadcastUITests.swift */,
145 | 711EF99526C959A700FD8A9F /* Info.plist */,
146 | );
147 | path = BroadcastUITests;
148 | sourceTree = "";
149 | };
150 | 7188E61C2687B0FE007CFD78 = {
151 | isa = PBXGroup;
152 | children = (
153 | 7188E6272687B0FE007CFD78 /* Broadcast */,
154 | 711EF99226C959A700FD8A9F /* BroadcastUITests */,
155 | 7188E6262687B0FE007CFD78 /* Products */,
156 | );
157 | sourceTree = "";
158 | };
159 | 7188E6262687B0FE007CFD78 /* Products */ = {
160 | isa = PBXGroup;
161 | children = (
162 | 7188E6252687B0FE007CFD78 /* Broadcast.app */,
163 | 711EF99126C959A700FD8A9F /* BroadcastUITests.xctest */,
164 | );
165 | name = Products;
166 | sourceTree = "";
167 | };
168 | 7188E6272687B0FE007CFD78 /* Broadcast */ = {
169 | isa = PBXGroup;
170 | children = (
171 | 7188E6312687B0FF007CFD78 /* Info.plist */,
172 | 7188E6282687B0FE007CFD78 /* BroadcastApp.swift */,
173 | 7188E62A2687B0FE007CFD78 /* ContentView.swift */,
174 | 7188E65E26889147007CFD78 /* SignOutView.swift */,
175 | 7188E6622688A0FC007CFD78 /* WelcomeView.swift */,
176 | 7188E62C2687B0FF007CFD78 /* Assets.xcassets */,
177 | 7188E6392687B192007CFD78 /* Extensions */,
178 | 7188E6452687B6AF007CFD78 /* Helper Views */,
179 | 7188E6402687B431007CFD78 /* Helpers */,
180 | 7188E62E2687B0FF007CFD78 /* Preview Content */,
181 | 71BBAAEA268CF532004048A0 /* TwitterAPI-Info.plist */,
182 | 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */,
183 | 71800BFB26999B1B009D11A1 /* DraftsModel.xcdatamodeld */,
184 | );
185 | path = Broadcast;
186 | sourceTree = "";
187 | };
188 | 7188E62E2687B0FF007CFD78 /* Preview Content */ = {
189 | isa = PBXGroup;
190 | children = (
191 | 7188E62F2687B0FF007CFD78 /* Preview Assets.xcassets */,
192 | );
193 | path = "Preview Content";
194 | sourceTree = "";
195 | };
196 | 7188E6392687B192007CFD78 /* Extensions */ = {
197 | isa = PBXGroup;
198 | children = (
199 | 711F3FF9268F50C800605C89 /* Animation.extension.swift */,
200 | 719087CE26891C7F005B96CE /* Array.extension.swift */,
201 | 7188E6602688A01F007CFD78 /* Font.extension.swift */,
202 | 7188E63A2687B19D007CFD78 /* Notification.extension.swift */,
203 | 7188E64A2687C44D007CFD78 /* String.extension.swift */,
204 | 7188E66B2688DB44007CFD78 /* UIApplication.extension.swift */,
205 | 71800BF7269998BF009D11A1 /* UIImage.extension.swift */,
206 | 717041F626B703A600001360 /* TwitterClient+MockTweet.swift */,
207 | 7199AE7926B96D0D001DEB46 /* NSRegularExpression+Convenience.swift */,
208 | );
209 | path = Extensions;
210 | sourceTree = "";
211 | };
212 | 7188E6402687B431007CFD78 /* Helpers */ = {
213 | isa = PBXGroup;
214 | children = (
215 | 7188E64C2687C6A1007CFD78 /* Binding.extension.swift */,
216 | 71A10889268B073B007E1FFB /* Haptics.swift */,
217 | 71E36FFA2689EED40078D956 /* ShakeModifier.swift */,
218 | 7188E6642688A436007CFD78 /* ThemeHelper.swift */,
219 | 71B8290B268D0AC6002AEE72 /* TwitterClient.swift */,
220 | 71800BFE26999BA6009D11A1 /* PersistanceController.swift */,
221 | 7199AE7F26B9892E001DEB46 /* Debouncer.swift */,
222 | 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */,
223 | );
224 | path = Helpers;
225 | sourceTree = "";
226 | };
227 | 7188E6452687B6AF007CFD78 /* Helper Views */ = {
228 | isa = PBXGroup;
229 | children = (
230 | 71800BF126906721009D11A1 /* ActionBarView.swift */,
231 | 7188E6522687D16C007CFD78 /* AttachmentThumbnail.swift */,
232 | 7188E6482687C0E0007CFD78 /* BroadcastButtonStyle.swift */,
233 | 71BBAAE8268CF1BD004048A0 /* ComposerView.swift */,
234 | 71800BF326906BC0009D11A1 /* DraftsListView.swift */,
235 | 71800BEF26905DB3009D11A1 /* EngagementCountersView.swift */,
236 | 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */,
237 | 7188E64E2687CCD0007CFD78 /* PhotoPicker.swift */,
238 | 71AA4AC5268A032400B7B577 /* RemoteImage.swift */,
239 | 7188E6462687B6C2007CFD78 /* SafariView.swift */,
240 | 7188E6662688B99E007CFD78 /* VisualEffectView.swift */,
241 | 717041F226B6FFEA00001360 /* RepliesListView.swift */,
242 | 717041F426B7037300001360 /* TweetView.swift */,
243 | 7199AE7B26B97327001DEB46 /* UserView.swift */,
244 | 7199AE7D26B973B2001DEB46 /* MentionBar.swift */,
245 | 7101073526C810AC00A713A5 /* NullStateView.swift */,
246 | );
247 | path = "Helper Views";
248 | sourceTree = "";
249 | };
250 | /* End PBXGroup section */
251 |
252 | /* Begin PBXNativeTarget section */
253 | 711EF99026C959A700FD8A9F /* BroadcastUITests */ = {
254 | isa = PBXNativeTarget;
255 | buildConfigurationList = 711EF99A26C959A700FD8A9F /* Build configuration list for PBXNativeTarget "BroadcastUITests" */;
256 | buildPhases = (
257 | 711EF98D26C959A700FD8A9F /* Sources */,
258 | 711EF98E26C959A700FD8A9F /* Frameworks */,
259 | 711EF98F26C959A700FD8A9F /* Resources */,
260 | );
261 | buildRules = (
262 | );
263 | dependencies = (
264 | 711EF99726C959A700FD8A9F /* PBXTargetDependency */,
265 | );
266 | name = BroadcastUITests;
267 | productName = BroadcastUITests;
268 | productReference = 711EF99126C959A700FD8A9F /* BroadcastUITests.xctest */;
269 | productType = "com.apple.product-type.bundle.ui-testing";
270 | };
271 | 7188E6242687B0FE007CFD78 /* Broadcast */ = {
272 | isa = PBXNativeTarget;
273 | buildConfigurationList = 7188E6342687B0FF007CFD78 /* Build configuration list for PBXNativeTarget "Broadcast" */;
274 | buildPhases = (
275 | 7188E6212687B0FE007CFD78 /* Sources */,
276 | 7188E6222687B0FE007CFD78 /* Frameworks */,
277 | 7188E6232687B0FE007CFD78 /* Resources */,
278 | 71B82911268F2A34002AEE72 /* ShellScript */,
279 | );
280 | buildRules = (
281 | );
282 | dependencies = (
283 | );
284 | name = Broadcast;
285 | packageProductDependencies = (
286 | 7188E65C26887DCD007CFD78 /* SwiftKeychainWrapper */,
287 | 7188E6692688D7BA007CFD78 /* Introspect */,
288 | 719087CC26891586005B96CE /* TwitterText */,
289 | 715AAE0926C923A1002BCEA1 /* Swifter */,
290 | );
291 | productName = Broadcast;
292 | productReference = 7188E6252687B0FE007CFD78 /* Broadcast.app */;
293 | productType = "com.apple.product-type.application";
294 | };
295 | /* End PBXNativeTarget section */
296 |
297 | /* Begin PBXProject section */
298 | 7188E61D2687B0FE007CFD78 /* Project object */ = {
299 | isa = PBXProject;
300 | attributes = {
301 | LastSwiftUpdateCheck = 1250;
302 | LastUpgradeCheck = 1250;
303 | TargetAttributes = {
304 | 711EF99026C959A700FD8A9F = {
305 | CreatedOnToolsVersion = 12.5.1;
306 | TestTargetID = 7188E6242687B0FE007CFD78;
307 | };
308 | 7188E6242687B0FE007CFD78 = {
309 | CreatedOnToolsVersion = 12.5.1;
310 | };
311 | };
312 | };
313 | buildConfigurationList = 7188E6202687B0FE007CFD78 /* Build configuration list for PBXProject "Broadcast" */;
314 | compatibilityVersion = "Xcode 9.3";
315 | developmentRegion = en;
316 | hasScannedForEncodings = 0;
317 | knownRegions = (
318 | en,
319 | Base,
320 | );
321 | mainGroup = 7188E61C2687B0FE007CFD78;
322 | packageReferences = (
323 | 7188E65B26887DCD007CFD78 /* XCRemoteSwiftPackageReference "SwiftKeychainWrapper" */,
324 | 7188E6682688D7BA007CFD78 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */,
325 | 719087CB26891586005B96CE /* XCRemoteSwiftPackageReference "twitter-text" */,
326 | 715AAE0826C923A1002BCEA1 /* XCRemoteSwiftPackageReference "Swifter" */,
327 | );
328 | productRefGroup = 7188E6262687B0FE007CFD78 /* Products */;
329 | projectDirPath = "";
330 | projectRoot = "";
331 | targets = (
332 | 7188E6242687B0FE007CFD78 /* Broadcast */,
333 | 711EF99026C959A700FD8A9F /* BroadcastUITests */,
334 | );
335 | };
336 | /* End PBXProject section */
337 |
338 | /* Begin PBXResourcesBuildPhase section */
339 | 711EF98F26C959A700FD8A9F /* Resources */ = {
340 | isa = PBXResourcesBuildPhase;
341 | buildActionMask = 2147483647;
342 | files = (
343 | );
344 | runOnlyForDeploymentPostprocessing = 0;
345 | };
346 | 7188E6232687B0FE007CFD78 /* Resources */ = {
347 | isa = PBXResourcesBuildPhase;
348 | buildActionMask = 2147483647;
349 | files = (
350 | 7188E6302687B0FF007CFD78 /* Preview Assets.xcassets in Resources */,
351 | 71BBAAEB268CF532004048A0 /* TwitterAPI-Info.plist in Resources */,
352 | 7188E62D2687B0FF007CFD78 /* Assets.xcassets in Resources */,
353 | 71A6A264278C73AD00BF2387 /* TwitterAPI-Info.example.plist in Resources */,
354 | );
355 | runOnlyForDeploymentPostprocessing = 0;
356 | };
357 | /* End PBXResourcesBuildPhase section */
358 |
359 | /* Begin PBXShellScriptBuildPhase section */
360 | 71B82911268F2A34002AEE72 /* ShellScript */ = {
361 | isa = PBXShellScriptBuildPhase;
362 | buildActionMask = 2147483647;
363 | files = (
364 | );
365 | inputFileListPaths = (
366 | );
367 | inputPaths = (
368 | );
369 | outputFileListPaths = (
370 | );
371 | outputPaths = (
372 | );
373 | runOnlyForDeploymentPostprocessing = 0;
374 | shellPath = /bin/sh;
375 | shellScript = "CONFIG_FILE_BASE_NAME=\"TwitterAPI-Info\"\nCONFIG_FILE_NAME=${CONFIG_FILE_BASE_NAME}.plist\nSAMPLE_CONFIG_FILE_NAME=${CONFIG_FILE_BASE_NAME}-Sample.plist\nCONFIG_FILE_PATH=$SRCROOT/$PRODUCT_NAME/$CONFIG_FILE_NAME\nSAMPLE_CONFIG_FILE_PATH=$SRCROOT/$PRODUCT_NAME/$SAMPLE_CONFIG_FILE_NAME\nif [ -f \"$CONFIG_FILE_PATH\" ]; then\n echo \"$CONFIG_FILE_PATH exists.\"\nelse\n echo \"$CONFIG_FILE_PATH does not exist, copying sample\"\n cp -v \"${SAMPLE_CONFIG_FILE_PATH}\" \"${CONFIG_FILE_PATH}\"\nfi\n";
376 | };
377 | /* End PBXShellScriptBuildPhase section */
378 |
379 | /* Begin PBXSourcesBuildPhase section */
380 | 711EF98D26C959A700FD8A9F /* Sources */ = {
381 | isa = PBXSourcesBuildPhase;
382 | buildActionMask = 2147483647;
383 | files = (
384 | 711EF99426C959A700FD8A9F /* BroadcastUITests.swift in Sources */,
385 | );
386 | runOnlyForDeploymentPostprocessing = 0;
387 | };
388 | 7188E6212687B0FE007CFD78 /* Sources */ = {
389 | isa = PBXSourcesBuildPhase;
390 | buildActionMask = 2147483647;
391 | files = (
392 | 7188E6612688A01F007CFD78 /* Font.extension.swift in Sources */,
393 | 7188E62B2687B0FE007CFD78 /* ContentView.swift in Sources */,
394 | 717041F526B7037300001360 /* TweetView.swift in Sources */,
395 | 7188E63B2687B19D007CFD78 /* Notification.extension.swift in Sources */,
396 | 717041F326B6FFEA00001360 /* RepliesListView.swift in Sources */,
397 | 7188E6292687B0FE007CFD78 /* BroadcastApp.swift in Sources */,
398 | 71B8290C268D0AC6002AEE72 /* TwitterClient.swift in Sources */,
399 | 7188E6632688A0FC007CFD78 /* WelcomeView.swift in Sources */,
400 | 7199AE7A26B96D0D001DEB46 /* NSRegularExpression+Convenience.swift in Sources */,
401 | 717041F726B703A600001360 /* TwitterClient+MockTweet.swift in Sources */,
402 | 7188E6472687B6C2007CFD78 /* SafariView.swift in Sources */,
403 | 7188E6492687C0E0007CFD78 /* BroadcastButtonStyle.swift in Sources */,
404 | 71800BF026905DB3009D11A1 /* EngagementCountersView.swift in Sources */,
405 | 7199AE7C26B97327001DEB46 /* UserView.swift in Sources */,
406 | 7188E6652688A436007CFD78 /* ThemeHelper.swift in Sources */,
407 | 7199AE7E26B973B2001DEB46 /* MentionBar.swift in Sources */,
408 | 71800BF226906721009D11A1 /* ActionBarView.swift in Sources */,
409 | 7188E64F2687CCD0007CFD78 /* PhotoPicker.swift in Sources */,
410 | 7101073626C810AC00A713A5 /* NullStateView.swift in Sources */,
411 | 7188E66C2688DB44007CFD78 /* UIApplication.extension.swift in Sources */,
412 | 71800BFD26999B1B009D11A1 /* DraftsModel.xcdatamodeld in Sources */,
413 | 7188E6532687D16C007CFD78 /* AttachmentThumbnail.swift in Sources */,
414 | 7188E64B2687C44D007CFD78 /* String.extension.swift in Sources */,
415 | 71800BFF26999BA6009D11A1 /* PersistanceController.swift in Sources */,
416 | 71B82910268F2195002AEE72 /* LastTweetReplyView.swift in Sources */,
417 | 71800BF426906BC0009D11A1 /* DraftsListView.swift in Sources */,
418 | 71E36FFB2689EED40078D956 /* ShakeModifier.swift in Sources */,
419 | 71800BF8269998BF009D11A1 /* UIImage.extension.swift in Sources */,
420 | 719087CF26891C7F005B96CE /* Array.extension.swift in Sources */,
421 | 7188E64D2687C6A1007CFD78 /* Binding.extension.swift in Sources */,
422 | 7199AE8026B9892E001DEB46 /* Debouncer.swift in Sources */,
423 | 711F3FFA268F50C800605C89 /* Animation.extension.swift in Sources */,
424 | 71AA4AC6268A032400B7B577 /* RemoteImage.swift in Sources */,
425 | 7188E6672688B99E007CFD78 /* VisualEffectView.swift in Sources */,
426 | 7188E65F26889147007CFD78 /* SignOutView.swift in Sources */,
427 | 71A1088A268B073B007E1FFB /* Haptics.swift in Sources */,
428 | 71BBAAE9268CF1BD004048A0 /* ComposerView.swift in Sources */,
429 | 715AAE0C26C9589A002BCEA1 /* TestUtils.swift in Sources */,
430 | );
431 | runOnlyForDeploymentPostprocessing = 0;
432 | };
433 | /* End PBXSourcesBuildPhase section */
434 |
435 | /* Begin PBXTargetDependency section */
436 | 711EF99726C959A700FD8A9F /* PBXTargetDependency */ = {
437 | isa = PBXTargetDependency;
438 | target = 7188E6242687B0FE007CFD78 /* Broadcast */;
439 | targetProxy = 711EF99626C959A700FD8A9F /* PBXContainerItemProxy */;
440 | };
441 | /* End PBXTargetDependency section */
442 |
443 | /* Begin XCBuildConfiguration section */
444 | 711EF99826C959A700FD8A9F /* Debug */ = {
445 | isa = XCBuildConfiguration;
446 | buildSettings = {
447 | CODE_SIGN_STYLE = Automatic;
448 | DEVELOPMENT_TEAM = YC249PY26F;
449 | INFOPLIST_FILE = BroadcastUITests/Info.plist;
450 | LD_RUNPATH_SEARCH_PATHS = (
451 | "$(inherited)",
452 | "@executable_path/Frameworks",
453 | "@loader_path/Frameworks",
454 | );
455 | PRODUCT_BUNDLE_IDENTIFIER = me.daneden.BroadcastUITests;
456 | PRODUCT_NAME = "$(TARGET_NAME)";
457 | SWIFT_VERSION = 5.0;
458 | TARGETED_DEVICE_FAMILY = "1,2";
459 | TEST_TARGET_NAME = Broadcast;
460 | };
461 | name = Debug;
462 | };
463 | 711EF99926C959A700FD8A9F /* Release */ = {
464 | isa = XCBuildConfiguration;
465 | buildSettings = {
466 | CODE_SIGN_STYLE = Automatic;
467 | DEVELOPMENT_TEAM = YC249PY26F;
468 | INFOPLIST_FILE = BroadcastUITests/Info.plist;
469 | LD_RUNPATH_SEARCH_PATHS = (
470 | "$(inherited)",
471 | "@executable_path/Frameworks",
472 | "@loader_path/Frameworks",
473 | );
474 | PRODUCT_BUNDLE_IDENTIFIER = me.daneden.BroadcastUITests;
475 | PRODUCT_NAME = "$(TARGET_NAME)";
476 | SWIFT_VERSION = 5.0;
477 | TARGETED_DEVICE_FAMILY = "1,2";
478 | TEST_TARGET_NAME = Broadcast;
479 | };
480 | name = Release;
481 | };
482 | 7188E6322687B0FF007CFD78 /* Debug */ = {
483 | isa = XCBuildConfiguration;
484 | buildSettings = {
485 | ALWAYS_SEARCH_USER_PATHS = NO;
486 | CLANG_ANALYZER_NONNULL = YES;
487 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
488 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
489 | CLANG_CXX_LIBRARY = "libc++";
490 | CLANG_ENABLE_MODULES = YES;
491 | CLANG_ENABLE_OBJC_ARC = YES;
492 | CLANG_ENABLE_OBJC_WEAK = YES;
493 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
494 | CLANG_WARN_BOOL_CONVERSION = YES;
495 | CLANG_WARN_COMMA = YES;
496 | CLANG_WARN_CONSTANT_CONVERSION = YES;
497 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
498 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
499 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
500 | CLANG_WARN_EMPTY_BODY = YES;
501 | CLANG_WARN_ENUM_CONVERSION = YES;
502 | CLANG_WARN_INFINITE_RECURSION = YES;
503 | CLANG_WARN_INT_CONVERSION = YES;
504 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
505 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
506 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
507 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
508 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
509 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
510 | CLANG_WARN_STRICT_PROTOTYPES = YES;
511 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
512 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
513 | CLANG_WARN_UNREACHABLE_CODE = YES;
514 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
515 | COPY_PHASE_STRIP = NO;
516 | DEBUG_INFORMATION_FORMAT = dwarf;
517 | ENABLE_STRICT_OBJC_MSGSEND = YES;
518 | ENABLE_TESTABILITY = YES;
519 | GCC_C_LANGUAGE_STANDARD = gnu11;
520 | GCC_DYNAMIC_NO_PIC = NO;
521 | GCC_NO_COMMON_BLOCKS = YES;
522 | GCC_OPTIMIZATION_LEVEL = 0;
523 | GCC_PREPROCESSOR_DEFINITIONS = (
524 | "DEBUG=1",
525 | "$(inherited)",
526 | );
527 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
528 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
529 | GCC_WARN_UNDECLARED_SELECTOR = YES;
530 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
531 | GCC_WARN_UNUSED_FUNCTION = YES;
532 | GCC_WARN_UNUSED_VARIABLE = YES;
533 | IPHONEOS_DEPLOYMENT_TARGET = 14.5;
534 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
535 | MTL_FAST_MATH = YES;
536 | ONLY_ACTIVE_ARCH = YES;
537 | SDKROOT = iphoneos;
538 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
539 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
540 | };
541 | name = Debug;
542 | };
543 | 7188E6332687B0FF007CFD78 /* Release */ = {
544 | isa = XCBuildConfiguration;
545 | buildSettings = {
546 | ALWAYS_SEARCH_USER_PATHS = NO;
547 | CLANG_ANALYZER_NONNULL = YES;
548 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
549 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
550 | CLANG_CXX_LIBRARY = "libc++";
551 | CLANG_ENABLE_MODULES = YES;
552 | CLANG_ENABLE_OBJC_ARC = YES;
553 | CLANG_ENABLE_OBJC_WEAK = YES;
554 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
555 | CLANG_WARN_BOOL_CONVERSION = YES;
556 | CLANG_WARN_COMMA = YES;
557 | CLANG_WARN_CONSTANT_CONVERSION = YES;
558 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
559 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
560 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
561 | CLANG_WARN_EMPTY_BODY = YES;
562 | CLANG_WARN_ENUM_CONVERSION = YES;
563 | CLANG_WARN_INFINITE_RECURSION = YES;
564 | CLANG_WARN_INT_CONVERSION = YES;
565 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
566 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
567 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
568 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
569 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
570 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
571 | CLANG_WARN_STRICT_PROTOTYPES = YES;
572 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
573 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
574 | CLANG_WARN_UNREACHABLE_CODE = YES;
575 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
576 | COPY_PHASE_STRIP = NO;
577 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
578 | ENABLE_NS_ASSERTIONS = NO;
579 | ENABLE_STRICT_OBJC_MSGSEND = YES;
580 | GCC_C_LANGUAGE_STANDARD = gnu11;
581 | GCC_NO_COMMON_BLOCKS = YES;
582 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
583 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
584 | GCC_WARN_UNDECLARED_SELECTOR = YES;
585 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
586 | GCC_WARN_UNUSED_FUNCTION = YES;
587 | GCC_WARN_UNUSED_VARIABLE = YES;
588 | IPHONEOS_DEPLOYMENT_TARGET = 14.5;
589 | MTL_ENABLE_DEBUG_INFO = NO;
590 | MTL_FAST_MATH = YES;
591 | SDKROOT = iphoneos;
592 | SWIFT_COMPILATION_MODE = wholemodule;
593 | SWIFT_OPTIMIZATION_LEVEL = "-O";
594 | VALIDATE_PRODUCT = YES;
595 | };
596 | name = Release;
597 | };
598 | 7188E6352687B0FF007CFD78 /* Debug */ = {
599 | isa = XCBuildConfiguration;
600 | buildSettings = {
601 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
602 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
603 | CODE_SIGN_STYLE = Automatic;
604 | CURRENT_PROJECT_VERSION = 38;
605 | DEVELOPMENT_ASSET_PATHS = "\"Broadcast/Preview Content\"";
606 | DEVELOPMENT_TEAM = YC249PY26F;
607 | ENABLE_PREVIEWS = YES;
608 | INFOPLIST_FILE = Broadcast/Info.plist;
609 | IPHONEOS_DEPLOYMENT_TARGET = 14.0;
610 | LD_RUNPATH_SEARCH_PATHS = (
611 | "$(inherited)",
612 | "@executable_path/Frameworks",
613 | );
614 | MARKETING_VERSION = 1.0.1;
615 | PRODUCT_BUNDLE_IDENTIFIER = me.daneden.Broadcast;
616 | PRODUCT_NAME = "$(TARGET_NAME)";
617 | SWIFT_VERSION = 5.0;
618 | TARGETED_DEVICE_FAMILY = "1,2";
619 | };
620 | name = Debug;
621 | };
622 | 7188E6362687B0FF007CFD78 /* Release */ = {
623 | isa = XCBuildConfiguration;
624 | buildSettings = {
625 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
626 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
627 | CODE_SIGN_STYLE = Automatic;
628 | CURRENT_PROJECT_VERSION = 38;
629 | DEVELOPMENT_ASSET_PATHS = "\"Broadcast/Preview Content\"";
630 | DEVELOPMENT_TEAM = YC249PY26F;
631 | ENABLE_PREVIEWS = YES;
632 | INFOPLIST_FILE = Broadcast/Info.plist;
633 | IPHONEOS_DEPLOYMENT_TARGET = 14.0;
634 | LD_RUNPATH_SEARCH_PATHS = (
635 | "$(inherited)",
636 | "@executable_path/Frameworks",
637 | );
638 | MARKETING_VERSION = 1.0.1;
639 | PRODUCT_BUNDLE_IDENTIFIER = me.daneden.Broadcast;
640 | PRODUCT_NAME = "$(TARGET_NAME)";
641 | SWIFT_VERSION = 5.0;
642 | TARGETED_DEVICE_FAMILY = "1,2";
643 | };
644 | name = Release;
645 | };
646 | /* End XCBuildConfiguration section */
647 |
648 | /* Begin XCConfigurationList section */
649 | 711EF99A26C959A700FD8A9F /* Build configuration list for PBXNativeTarget "BroadcastUITests" */ = {
650 | isa = XCConfigurationList;
651 | buildConfigurations = (
652 | 711EF99826C959A700FD8A9F /* Debug */,
653 | 711EF99926C959A700FD8A9F /* Release */,
654 | );
655 | defaultConfigurationIsVisible = 0;
656 | defaultConfigurationName = Release;
657 | };
658 | 7188E6202687B0FE007CFD78 /* Build configuration list for PBXProject "Broadcast" */ = {
659 | isa = XCConfigurationList;
660 | buildConfigurations = (
661 | 7188E6322687B0FF007CFD78 /* Debug */,
662 | 7188E6332687B0FF007CFD78 /* Release */,
663 | );
664 | defaultConfigurationIsVisible = 0;
665 | defaultConfigurationName = Release;
666 | };
667 | 7188E6342687B0FF007CFD78 /* Build configuration list for PBXNativeTarget "Broadcast" */ = {
668 | isa = XCConfigurationList;
669 | buildConfigurations = (
670 | 7188E6352687B0FF007CFD78 /* Debug */,
671 | 7188E6362687B0FF007CFD78 /* Release */,
672 | );
673 | defaultConfigurationIsVisible = 0;
674 | defaultConfigurationName = Release;
675 | };
676 | /* End XCConfigurationList section */
677 |
678 | /* Begin XCRemoteSwiftPackageReference section */
679 | 715AAE0826C923A1002BCEA1 /* XCRemoteSwiftPackageReference "Swifter" */ = {
680 | isa = XCRemoteSwiftPackageReference;
681 | repositoryURL = "https://github.com/daneden/Swifter";
682 | requirement = {
683 | kind = upToNextMajorVersion;
684 | minimumVersion = 2.5.1;
685 | };
686 | };
687 | 7188E65B26887DCD007CFD78 /* XCRemoteSwiftPackageReference "SwiftKeychainWrapper" */ = {
688 | isa = XCRemoteSwiftPackageReference;
689 | repositoryURL = "https://github.com/jrendel/SwiftKeychainWrapper";
690 | requirement = {
691 | kind = upToNextMajorVersion;
692 | minimumVersion = 4.0.1;
693 | };
694 | };
695 | 7188E6682688D7BA007CFD78 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = {
696 | isa = XCRemoteSwiftPackageReference;
697 | repositoryURL = "https://github.com/siteline/SwiftUI-Introspect";
698 | requirement = {
699 | kind = upToNextMajorVersion;
700 | minimumVersion = 0.1.3;
701 | };
702 | };
703 | 719087CB26891586005B96CE /* XCRemoteSwiftPackageReference "twitter-text" */ = {
704 | isa = XCRemoteSwiftPackageReference;
705 | repositoryURL = "https://github.com/nysander/twitter-text.git";
706 | requirement = {
707 | kind = upToNextMajorVersion;
708 | minimumVersion = 0.0.1;
709 | };
710 | };
711 | /* End XCRemoteSwiftPackageReference section */
712 |
713 | /* Begin XCSwiftPackageProductDependency section */
714 | 715AAE0926C923A1002BCEA1 /* Swifter */ = {
715 | isa = XCSwiftPackageProductDependency;
716 | package = 715AAE0826C923A1002BCEA1 /* XCRemoteSwiftPackageReference "Swifter" */;
717 | productName = Swifter;
718 | };
719 | 7188E65C26887DCD007CFD78 /* SwiftKeychainWrapper */ = {
720 | isa = XCSwiftPackageProductDependency;
721 | package = 7188E65B26887DCD007CFD78 /* XCRemoteSwiftPackageReference "SwiftKeychainWrapper" */;
722 | productName = SwiftKeychainWrapper;
723 | };
724 | 7188E6692688D7BA007CFD78 /* Introspect */ = {
725 | isa = XCSwiftPackageProductDependency;
726 | package = 7188E6682688D7BA007CFD78 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */;
727 | productName = Introspect;
728 | };
729 | 719087CC26891586005B96CE /* TwitterText */ = {
730 | isa = XCSwiftPackageProductDependency;
731 | package = 719087CB26891586005B96CE /* XCRemoteSwiftPackageReference "twitter-text" */;
732 | productName = TwitterText;
733 | };
734 | /* End XCSwiftPackageProductDependency section */
735 |
736 | /* Begin XCVersionGroup section */
737 | 71800BFB26999B1B009D11A1 /* DraftsModel.xcdatamodeld */ = {
738 | isa = XCVersionGroup;
739 | children = (
740 | 71800BFC26999B1B009D11A1 /* DraftsModel.xcdatamodel */,
741 | );
742 | currentVersion = 71800BFC26999B1B009D11A1 /* DraftsModel.xcdatamodel */;
743 | path = DraftsModel.xcdatamodeld;
744 | sourceTree = "";
745 | versionGroupType = wrapper.xcdatamodel;
746 | };
747 | /* End XCVersionGroup section */
748 | };
749 | rootObject = 7188E61D2687B0FE007CFD78 /* Project object */;
750 | }
751 |
--------------------------------------------------------------------------------