diff --git a/OpenTweet.xcodeproj/project.pbxproj b/OpenTweet.xcodeproj/project.pbxproj index 8ab1732..9e85913 100644 --- a/OpenTweet.xcodeproj/project.pbxproj +++ b/OpenTweet.xcodeproj/project.pbxproj @@ -15,6 +15,11 @@ 009C4C811D9F0CD600F0BC6C /* OpenTweetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 009C4C801D9F0CD600F0BC6C /* OpenTweetTests.swift */; }; 009C4C8C1D9F0CD600F0BC6C /* OpenTweetUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 009C4C8B1D9F0CD600F0BC6C /* OpenTweetUITests.swift */; }; 009C4C9B1D9F0D4100F0BC6C /* timeline.json in Resources */ = {isa = PBXBuildFile; fileRef = 009C4C9A1D9F0D4100F0BC6C /* timeline.json */; }; + E64573452B7088E50099B820 /* DataStructures.swift in Sources */ = {isa = PBXBuildFile; fileRef = E64573442B7088E50099B820 /* DataStructures.swift */; }; + E64573472B70890E0099B820 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = E64573462B70890E0099B820 /* Constants.swift */; }; + E64573492B7089440099B820 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = E64573482B7089440099B820 /* AppTheme.swift */; }; + E645734F2B71BA530099B820 /* TimelineTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E645734E2B71BA530099B820 /* TimelineTableViewController.swift */; }; + E64573512B71F4890099B820 /* convenienceExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E64573502B71F4880099B820 /* convenienceExtensions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -50,6 +55,11 @@ 009C4C8D1D9F0CD600F0BC6C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 009C4C9A1D9F0D4100F0BC6C /* timeline.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = timeline.json; sourceTree = ""; }; 009C4C9D1D9F104800F0BC6C /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + E64573442B7088E50099B820 /* DataStructures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStructures.swift; sourceTree = ""; }; + E64573462B70890E0099B820 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + E64573482B7089440099B820 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; }; + E645734E2B71BA530099B820 /* TimelineTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = ""; }; + E64573502B71F4880099B820 /* convenienceExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = convenienceExtensions.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -104,6 +114,11 @@ children = ( 009C4C6B1D9F0CD600F0BC6C /* AppDelegate.swift */, 009C4C6D1D9F0CD600F0BC6C /* TimelineViewController.swift */, + E645734E2B71BA530099B820 /* TimelineTableViewController.swift */, + E64573442B7088E50099B820 /* DataStructures.swift */, + E64573462B70890E0099B820 /* Constants.swift */, + E64573482B7089440099B820 /* AppTheme.swift */, + E64573502B71F4880099B820 /* convenienceExtensions.swift */, 009C4C6F1D9F0CD600F0BC6C /* Main.storyboard */, 009C4C741D9F0CD600F0BC6C /* LaunchScreen.storyboard */, 009C4C721D9F0CD600F0BC6C /* Assets.xcassets */, @@ -276,8 +291,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E64573512B71F4890099B820 /* convenienceExtensions.swift in Sources */, + E64573452B7088E50099B820 /* DataStructures.swift in Sources */, + E64573492B7089440099B820 /* AppTheme.swift in Sources */, 009C4C6E1D9F0CD600F0BC6C /* TimelineViewController.swift in Sources */, 009C4C6C1D9F0CD600F0BC6C /* AppDelegate.swift in Sources */, + E645734F2B71BA530099B820 /* TimelineTableViewController.swift in Sources */, + E64573472B70890E0099B820 /* Constants.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -453,7 +473,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; DEVELOPMENT_TEAM = QPU8QS3E62; INFOPLIST_FILE = OpenTweet/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.opentable.OpenTweet; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -467,7 +487,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; DEVELOPMENT_TEAM = QPU8QS3E62; INFOPLIST_FILE = OpenTweet/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.opentable.OpenTweet; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/OpenTweet/AppDelegate.swift b/OpenTweet/AppDelegate.swift index ac68abf..14e8c69 100644 --- a/OpenTweet/AppDelegate.swift +++ b/OpenTweet/AppDelegate.swift @@ -16,6 +16,21 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. + + // Deal with navigation bar colours changing + if #available(iOS 15, *) { + // MARK: Navigation bar appearance + let navigationBarAppearance = UINavigationBarAppearance() + navigationBarAppearance.configureWithOpaqueBackground() + navigationBarAppearance.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor : UIColor.white + ] + navigationBarAppearance.backgroundColor = theme.background + UINavigationBar.appearance().standardAppearance = navigationBarAppearance + UINavigationBar.appearance().compactAppearance = navigationBarAppearance + UINavigationBar.appearance().scrollEdgeAppearance = navigationBarAppearance + } + return true } diff --git a/OpenTweet/AppTheme.swift b/OpenTweet/AppTheme.swift new file mode 100644 index 0000000..3998e21 --- /dev/null +++ b/OpenTweet/AppTheme.swift @@ -0,0 +1,34 @@ +// +// AppTheme.swift +// OpenTweet +// +// Created by Jesper Rage on 2024-02-04. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import UIKit + +// Todo impilment options +enum ColourMode:String { + case light = "Light Mode" + case dark = "Dark Mode" + case os = "Match OS" +} + + +// Todo make struct with getters and setters that access user defaults +struct AppTheme { + let textTitle: UIColor + let textBody: UIColor + let textHighlight: UIColor + let background: UIColor + let forground: UIColor + let trim: UIColor + + let titleFont: UIFont + let bodyFont: UIFont + let replyFont: UIFont + let dateFont: UIFont + let replyCountFont: UIFont +} + diff --git a/OpenTweet/Base.lproj/Main.storyboard b/OpenTweet/Base.lproj/Main.storyboard index f8b7281..3da4cfc 100644 --- a/OpenTweet/Base.lproj/Main.storyboard +++ b/OpenTweet/Base.lproj/Main.storyboard @@ -1,11 +1,29 @@ - + + - + + + + + + + + + + + + + + + + + + @@ -15,13 +33,167 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/OpenTweet/Constants.swift b/OpenTweet/Constants.swift new file mode 100644 index 0000000..ff9853e --- /dev/null +++ b/OpenTweet/Constants.swift @@ -0,0 +1,33 @@ +// +// Constants.swift +// OpenTweet +// +// Created by Jesper Rage on 2024-02-04. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import UIKit + + +// Colours +// Dark Colour Scheme +let theme = AppTheme( + textTitle: UIColor(hexString: "E7E7E7"), + textBody: UIColor(hexString: "DEDEDE"), + textHighlight: UIColor(hexString: "4A9AE7"), + background: .black, + forground: .black, + trim: UIColor(hexString: "727272"), + + //Fonts + titleFont: UIFont.systemFont(ofSize: 14, weight: UIFont.Weight.semibold), + bodyFont: UIFont.systemFont(ofSize: 12, weight: UIFont.Weight.regular), + replyFont: UIFont.systemFont(ofSize: 14, weight: UIFont.Weight.regular), + dateFont: UIFont.systemFont(ofSize: 13, weight: UIFont.Weight.regular), + replyCountFont: UIFont.systemFont(ofSize: 12, weight: UIFont.Weight.regular)) + +// Data locations +let timelineFileName = "timeline" + +// Variable +let timelineOrder = TimelineOrder.oldestToLatest diff --git a/OpenTweet/DataStructures.swift b/OpenTweet/DataStructures.swift new file mode 100644 index 0000000..4ab65c9 --- /dev/null +++ b/OpenTweet/DataStructures.swift @@ -0,0 +1,75 @@ +// +// DataStructures.swift +// OpenTweet +// +// Created by Jesper Rage on 2024-02-04. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import UIKit + +enum TimelineOrder { + case oldestToLatest + case latestToOldest +} + +struct Timeline: Decodable { + var timeline: [Tweet] + + + // returns Tweet pointed to by inReplyTo + func originalPost(inReplyTo replyTweet: Tweet) -> Tweet? { + if let replyId = replyTweet.inReplyTo { + for tweet in timeline { + if tweet.id == replyId { + return tweet + } + } + } + return nil + } + + // Recursive reply search + // Todo improve search algorithm, by relying on it being sorted, or only sort at the end + func getReplies(to tweet: Tweet) -> [Tweet]? { + var replies = [Tweet]() + + for possibleReply in timeline { + if possibleReply.inReplyTo == tweet.id { + + replies += [possibleReply] + + if let recursiveReplies = getReplies(to: possibleReply) { + replies += recursiveReplies + } + } + } + + // Sort based on date + if replies.count > 0 { + return sort(tweets: replies) + } else { + return nil + } + } + + // Sort based on date, newest to oldest + func sort(tweets: [Tweet]?) -> [Tweet]? { + if timelineOrder == .oldestToLatest { + return tweets?.sorted { $0.date < $1.date } + } else { + return tweets?.sorted { $0.date > $1.date } + } + } +} + +struct Tweet: Decodable { + let id: String + let author: String + let avatar: String? + let content: String + let inReplyTo: String? + let date: Date +} + + diff --git a/OpenTweet/TimelineTableViewController.swift b/OpenTweet/TimelineTableViewController.swift new file mode 100644 index 0000000..8f338a4 --- /dev/null +++ b/OpenTweet/TimelineTableViewController.swift @@ -0,0 +1,217 @@ +// +// TimelineTableViewController.swift +// OpenTweet +// +// Created by Jesper Rage on 2024-02-05. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import UIKit +import RegexBuilder + +class TweetCell: UITableViewCell { + + @IBOutlet weak var avatarImageView: UIImageView! + + @IBOutlet weak var messageTextView: UITextView! + @IBOutlet weak var authorLabel: UILabel! + @IBOutlet weak var dateLabel: UILabel! + @IBOutlet weak var replyCountLabel: UILabel! + @IBOutlet weak var replyBubble: UIImageView! + @IBOutlet weak var replyLabel: UILabel! +} + +class TimelineTableViewController: UITableViewController { + var focusedTimelineOnTweet: Tweet? // Set if controller is showing replies for Tweet + var timeline: Timeline = Timeline(timeline: [Tweet]()) + var avatarCache = [URL: UIImage]() + + override func viewDidLoad() { + super.viewDidLoad() + + // Update colours + view.backgroundColor = theme.background + tableView.backgroundColor = theme.background + tableView.separatorColor = theme.trim + + + } + + // MARK: - Table view data source + + override func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return timeline.timeline.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "TweetCell", for: indexPath) as! TweetCell + + + // Configure the cell... + let tweet = timeline.timeline[indexPath.row] + + // Get Avatar + Task { + do { + cell.avatarImageView.image = try await avatar(for: tweet) + } catch { + print("Error cell for indexPath \(indexPath) \(error.localizedDescription)") + } + } + + // Round image view / Give default colour for non avatar users + cell.avatarImageView.contentMode = .scaleAspectFill + cell.avatarImageView.layer.borderWidth = 1 + cell.avatarImageView.layer.masksToBounds = false + cell.avatarImageView.layer.borderColor = UIColor.black.cgColor + cell.avatarImageView.layer.cornerRadius = cell.avatarImageView.frame.height/2 + cell.avatarImageView.clipsToBounds = true + cell.avatarImageView.backgroundColor = .tintColor + + // Author + cell.authorLabel.text = tweet.author + + // Reply + if let inReplyTo = timeline.originalPost(inReplyTo: tweet) { + cell.replyLabel.text = "In reply to " + inReplyTo.author + } else { + cell.replyLabel.text = "" + } + + // Date label + let formatter = DateFormatter() + formatter.dateFormat = "MMM dd, yyyy" + formatter.string(from: tweet.date) + cell.dateLabel.text = " - " + formatter.string(from: tweet.date) + + // set message + cell.messageTextView.text = tweet.content + var attributedString = NSMutableAttributedString(string: tweet.content) + let range = NSRange(location: 0, length: attributedString.length) + + // Set all text to AppTheme colour + attributedString.addAttribute(.foregroundColor, value: theme.textBody, range: range) + + // Search for links in the text + let types: NSTextCheckingResult.CheckingType = .link + let detector = try? NSDataDetector(types: types.rawValue) + + if let detect = detector { + + let matches = detect.matches(in: tweet.content, options: .reportCompletion, range: NSMakeRange(0, tweet.content.count)) + + // For every link highlight only the link + for match in matches { + attributedString.addAttribute(.foregroundColor, value: theme.textHighlight, range: match.range) + } + } + + // Search for Authors mentions + let authorSearch = Regex { + "@" + Capture { + OneOrMore(.word) + } + } + + let matches = tweet.content.matches(of: authorSearch) + for match in matches { + + // Fastest way I could figure how to convert Range to NSRange + // Regex returns confirmed match strings, so search for the NSRange from the NSSTring that you know has the string + let range = NSString(string: tweet.content).range(of: String(match.output.0)) + + attributedString.addAttribute(.foregroundColor, value: theme.textHighlight, range: range) + + } + attributedString.addAttribute(.font, value: theme.bodyFont, range: range) + + // Set messageView the now complicated attributed string + cell.messageTextView.attributedText = attributedString + + + // set reply count + if let replies = timeline.getReplies(to: tweet) { + cell.replyCountLabel.text = "\(replies.count)" + } else { + cell.replyCountLabel.text = "" + } + + // Update Theme + cell.authorLabel.textColor = theme.textTitle + cell.authorLabel.font = theme.titleFont + cell.replyLabel.textColor = theme.textBody + cell.replyLabel.font = theme.replyFont + cell.dateLabel.textColor = theme.textBody + cell.dateLabel.font = theme.dateFont + cell.contentView.backgroundColor = theme.background + cell.replyBubble.tintColor = theme.trim + cell.replyCountLabel.textColor = theme.trim + cell.replyCountLabel.font = theme.replyCountFont + + return cell + } + + // Selecting a tweet will push a new timeline of the replies to that tweet + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + + // get tweet + let tweet = timeline.timeline[indexPath.row] + + // Stop double select + guard focusedTimelineOnTweet?.id != tweet.id else { + return + } + + // get replies + var nestedTimeline = Timeline(timeline: [tweet]) + if let replies = timeline.getReplies(to: tweet) { + nestedTimeline.timeline += replies + } + + let storyboard = UIStoryboard(name:"Main", bundle: nil) + let nestedReplyTimeline = storyboard.instantiateViewController(withIdentifier: "TimelineTableViewController") as! TimelineTableViewController + + // Push a timeline with just the reply content + nestedReplyTimeline.timeline = nestedTimeline + nestedReplyTimeline.focusedTimelineOnTweet = tweet + nestedReplyTimeline.avatarCache = avatarCache + self.navigationController?.pushViewController (nestedReplyTimeline, animated: true) + } + + override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return UITableView.automaticDimension + } + + + // Returns avatar from cache, if its not there it attempts downloading + func avatar(for tweet: Tweet) async throws -> UIImage? { + if let avatar = tweet.avatar { + if let url = URL(string: avatar) { + + if let image = avatarCache[url] { + return image + } else { + + let imageData = try await self.downloadAvatarData(from: url) + guard let avatar = UIImage(data: imageData) else { + throw NSError(domain: "failed to load avatar image", code: 0, userInfo: nil) + } + self.avatarCache[url] = avatar + return avatar + } + } + } + return nil + } + + func downloadAvatarData(from url: URL) async throws -> Data { + let request = URLRequest(url: url) + let (data, _) = try await URLSession.shared.data(for: request) + return data + } +} diff --git a/OpenTweet/TimelineViewController.swift b/OpenTweet/TimelineViewController.swift index f96a784..28466bb 100644 --- a/OpenTweet/TimelineViewController.swift +++ b/OpenTweet/TimelineViewController.swift @@ -9,11 +9,68 @@ import UIKit class TimelineViewController: UIViewController { - + + @IBOutlet weak var timelineViewContainer: UIView! + weak var timelineTableView: TimelineTableViewController? + var imageCache = [String : UIImage]() + override func viewDidLoad() { super.viewDidLoad() - // Do any additional setup after loading the view, typically from a nib. + view.backgroundColor = theme.background } + func loadJson(fileName: String) -> Timeline? { + + guard let url = Bundle.main.url(forResource: fileName, withExtension: "json") + else { + print("loadJson url nil") + return nil + } + guard let data = try? Data(contentsOf: url) + else { + print("loadJson nil data") + return nil + } + + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let timeline = try decoder.decode(Timeline.self, from: data) + return timeline + + } catch DecodingError.dataCorrupted(let context) { + print(context) + } catch DecodingError.keyNotFound(let key, let context) { + print("loadJson Key '\(key)' not found:", context.debugDescription) + print("loadJson codingPath:", context.codingPath) + } catch DecodingError.valueNotFound(let value, let context) { + print("loadJson Value '\(value)' not found:", context.debugDescription) + print("loadJson codingPath:", context.codingPath) + } catch DecodingError.typeMismatch(let type, let context) { + print("loadJson Type '\(type)' mismatch:", context.debugDescription) + print("loadJson codingPath:", context.codingPath) + } catch { + print("loadJson error: ", error) + } + + return nil + } + + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "EmbedTimelineTableViewController" { + + timelineTableView = segue.destination as? TimelineTableViewController + + // Load timeline from json + let timeline = loadJson(fileName: timelineFileName) + + + // sort timeline incase it didnt start sorted + if let sorted = timeline?.sort(tweets: timeline?.timeline) { + timelineTableView?.timeline.timeline = sorted + } + } + } } diff --git a/OpenTweet/convenienceExtensions.swift b/OpenTweet/convenienceExtensions.swift new file mode 100644 index 0000000..4ced007 --- /dev/null +++ b/OpenTweet/convenienceExtensions.swift @@ -0,0 +1,32 @@ +// +// convenienceExtensions.swift +// OpenTweet +// +// Created by Jesper Rage on 2024-02-05. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import UIKit + + +extension UIColor { + // create colour with hex + convenience init(hexString: String) { + let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int = UInt64() + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + self.init(red: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a) / 255) + } +} +