diff --git a/.gitignore b/.gitignore index 330d167..8b16ddd 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,4 @@ fastlane/test_output # https://github.com/johnno1962/injectionforxcode iOSInjectionProject/ +*.DS_STORE \ No newline at end of file diff --git a/OpenTweet.xcodeproj/project.pbxproj b/OpenTweet.xcodeproj/project.pbxproj index 8ab1732..37ea4a4 100644 --- a/OpenTweet.xcodeproj/project.pbxproj +++ b/OpenTweet.xcodeproj/project.pbxproj @@ -9,12 +9,26 @@ /* Begin PBXBuildFile section */ 009C4C6C1D9F0CD600F0BC6C /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 009C4C6B1D9F0CD600F0BC6C /* AppDelegate.swift */; }; 009C4C6E1D9F0CD600F0BC6C /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 009C4C6D1D9F0CD600F0BC6C /* TimelineViewController.swift */; }; - 009C4C711D9F0CD600F0BC6C /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 009C4C6F1D9F0CD600F0BC6C /* Main.storyboard */; }; 009C4C731D9F0CD600F0BC6C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 009C4C721D9F0CD600F0BC6C /* Assets.xcassets */; }; 009C4C761D9F0CD600F0BC6C /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 009C4C741D9F0CD600F0BC6C /* LaunchScreen.storyboard */; }; - 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 */; }; + 3D687F352BA1630B00DBFA2D /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D687F342BA1630B00DBFA2D /* Date+Extensions.swift */; }; + 3D687F372BA1651D00DBFA2D /* Bundle+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D687F362BA1651D00DBFA2D /* Bundle+Extensions.swift */; }; + 3D687F392BA2162300DBFA2D /* TextDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D687F382BA2162300DBFA2D /* TextDetector.swift */; }; + 3D687F3C2BA21F4000DBFA2D /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D687F3B2BA21F4000DBFA2D /* AppCoordinator.swift */; }; + 3D687F3E2BA21F6000DBFA2D /* ThreadViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D687F3D2BA21F6000DBFA2D /* ThreadViewController.swift */; }; + 3D687F402BA21F9700DBFA2D /* ThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D687F3F2BA21F9700DBFA2D /* ThreadViewModel.swift */; }; + 3DAEE9822BA24A8C0069147E /* UIImageView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DAEE9812BA24A8C0069147E /* UIImageView+Extensions.swift */; }; + 3DAEE9862BA25FD90069147E /* TextDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DAEE9852BA25FD80069147E /* TextDetectorTests.swift */; }; + 3DAEE9882BA261920069147E /* BundleExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DAEE9872BA261920069147E /* BundleExtensionsTests.swift */; }; + 3DAEE98C2BA266820069147E /* TimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DAEE98B2BA266820069147E /* TimelineViewModel.swift */; }; + 3DAEE98E2BA26EE00069147E /* MockData.json in Resources */ = {isa = PBXBuildFile; fileRef = 3DAEE98D2BA26EE00069147E /* MockData.json */; }; + 3DC96E942BA12AD700CFCDE8 /* Tweet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DC96E932BA12AD700CFCDE8 /* Tweet.swift */; }; + 3DC96E962BA12B1000CFCDE8 /* TimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DC96E952BA12B1000CFCDE8 /* TimelineViewModel.swift */; }; + 3DC96E982BA12B7A00CFCDE8 /* TimelineService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DC96E972BA12B7A00CFCDE8 /* TimelineService.swift */; }; + 3DC96E9B2BA12D1A00CFCDE8 /* TweetCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DC96E9A2BA12D1A00CFCDE8 /* TweetCell.swift */; }; + 3DFEDDF42BA2A20A00E12E5B /* TweetCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DFEDDF32BA2A20A00E12E5B /* TweetCellView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -38,18 +52,32 @@ 009C4C681D9F0CD600F0BC6C /* OpenTweet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenTweet.app; sourceTree = BUILT_PRODUCTS_DIR; }; 009C4C6B1D9F0CD600F0BC6C /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 009C4C6D1D9F0CD600F0BC6C /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = ""; }; - 009C4C701D9F0CD600F0BC6C /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 009C4C721D9F0CD600F0BC6C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 009C4C751D9F0CD600F0BC6C /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 009C4C771D9F0CD600F0BC6C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 009C4C7C1D9F0CD600F0BC6C /* OpenTweetTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenTweetTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 009C4C801D9F0CD600F0BC6C /* OpenTweetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenTweetTests.swift; sourceTree = ""; }; 009C4C821D9F0CD600F0BC6C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 009C4C871D9F0CD600F0BC6C /* OpenTweetUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenTweetUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 009C4C8B1D9F0CD600F0BC6C /* OpenTweetUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenTweetUITests.swift; sourceTree = ""; }; 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 = ""; }; + 3D687F342BA1630B00DBFA2D /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; + 3D687F362BA1651D00DBFA2D /* Bundle+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extensions.swift"; sourceTree = ""; }; + 3D687F382BA2162300DBFA2D /* TextDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextDetector.swift; sourceTree = ""; }; + 3D687F3B2BA21F4000DBFA2D /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; + 3D687F3D2BA21F6000DBFA2D /* ThreadViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadViewController.swift; sourceTree = ""; }; + 3D687F3F2BA21F9700DBFA2D /* ThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadViewModel.swift; sourceTree = ""; }; + 3DAEE9812BA24A8C0069147E /* UIImageView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImageView+Extensions.swift"; sourceTree = ""; }; + 3DAEE9852BA25FD80069147E /* TextDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextDetectorTests.swift; sourceTree = ""; }; + 3DAEE9872BA261920069147E /* BundleExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleExtensionsTests.swift; sourceTree = ""; }; + 3DAEE98B2BA266820069147E /* TimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewModel.swift; sourceTree = ""; }; + 3DAEE98D2BA26EE00069147E /* MockData.json */ = {isa = PBXFileReference; explicitFileType = text.json; path = MockData.json; sourceTree = ""; }; + 3DC96E932BA12AD700CFCDE8 /* Tweet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tweet.swift; sourceTree = ""; }; + 3DC96E952BA12B1000CFCDE8 /* TimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewModel.swift; sourceTree = ""; }; + 3DC96E972BA12B7A00CFCDE8 /* TimelineService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineService.swift; sourceTree = ""; }; + 3DC96E9A2BA12D1A00CFCDE8 /* TweetCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TweetCell.swift; sourceTree = ""; }; + 3DFEDDF32BA2A20A00E12E5B /* TweetCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TweetCellView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -102,9 +130,13 @@ 009C4C6A1D9F0CD600F0BC6C /* OpenTweet */ = { isa = PBXGroup; children = ( + 3D687F3A2BA21F3700DBFA2D /* Coordinators */, + 3D687F332BA162FF00DBFA2D /* Extensions */, + 3DC96E902BA12ABB00CFCDE8 /* Models */, + 3DC96E8F2BA12AB100CFCDE8 /* Services */, + 3DC96E8E2BA12A9E00CFCDE8 /* Utilities */, + 3DC96E8C2BA12A8F00CFCDE8 /* ViewControllers */, 009C4C6B1D9F0CD600F0BC6C /* AppDelegate.swift */, - 009C4C6D1D9F0CD600F0BC6C /* TimelineViewController.swift */, - 009C4C6F1D9F0CD600F0BC6C /* Main.storyboard */, 009C4C741D9F0CD600F0BC6C /* LaunchScreen.storyboard */, 009C4C721D9F0CD600F0BC6C /* Assets.xcassets */, 009C4C771D9F0CD600F0BC6C /* Info.plist */, @@ -115,8 +147,11 @@ 009C4C7F1D9F0CD600F0BC6C /* OpenTweetTests */ = { isa = PBXGroup; children = ( - 009C4C801D9F0CD600F0BC6C /* OpenTweetTests.swift */, + 3DAEE9852BA25FD80069147E /* TextDetectorTests.swift */, + 3DAEE9872BA261920069147E /* BundleExtensionsTests.swift */, + 3DAEE98B2BA266820069147E /* TimelineViewModel.swift */, 009C4C821D9F0CD600F0BC6C /* Info.plist */, + 3DAEE98D2BA26EE00069147E /* MockData.json */, ); path = OpenTweetTests; sourceTree = ""; @@ -138,6 +173,69 @@ path = Data; sourceTree = ""; }; + 3D687F332BA162FF00DBFA2D /* Extensions */ = { + isa = PBXGroup; + children = ( + 3D687F362BA1651D00DBFA2D /* Bundle+Extensions.swift */, + 3D687F342BA1630B00DBFA2D /* Date+Extensions.swift */, + 3DAEE9812BA24A8C0069147E /* UIImageView+Extensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 3D687F3A2BA21F3700DBFA2D /* Coordinators */ = { + isa = PBXGroup; + children = ( + 3D687F3B2BA21F4000DBFA2D /* AppCoordinator.swift */, + ); + path = Coordinators; + sourceTree = ""; + }; + 3DC96E8C2BA12A8F00CFCDE8 /* ViewControllers */ = { + isa = PBXGroup; + children = ( + 3DC96E992BA12D0C00CFCDE8 /* Cells */, + 009C4C6D1D9F0CD600F0BC6C /* TimelineViewController.swift */, + 3DC96E952BA12B1000CFCDE8 /* TimelineViewModel.swift */, + 3D687F3D2BA21F6000DBFA2D /* ThreadViewController.swift */, + 3D687F3F2BA21F9700DBFA2D /* ThreadViewModel.swift */, + ); + path = ViewControllers; + sourceTree = ""; + }; + 3DC96E8E2BA12A9E00CFCDE8 /* Utilities */ = { + isa = PBXGroup; + children = ( + 3D687F382BA2162300DBFA2D /* TextDetector.swift */, + ); + path = Utilities; + sourceTree = ""; + }; + 3DC96E8F2BA12AB100CFCDE8 /* Services */ = { + isa = PBXGroup; + children = ( + 3DC96E972BA12B7A00CFCDE8 /* TimelineService.swift */, + ); + path = Services; + sourceTree = ""; + }; + 3DC96E902BA12ABB00CFCDE8 /* Models */ = { + isa = PBXGroup; + children = ( + 3DC96E932BA12AD700CFCDE8 /* Tweet.swift */, + ); + path = Models; + sourceTree = ""; + }; + 3DC96E992BA12D0C00CFCDE8 /* Cells */ = { + isa = PBXGroup; + children = ( + 3DC96E9A2BA12D1A00CFCDE8 /* TweetCell.swift */, + 3DFEDDF32BA2A20A00E12E5B /* TweetCellView.swift */, + ); + path = Cells; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -251,7 +349,6 @@ 009C4C9B1D9F0D4100F0BC6C /* timeline.json in Resources */, 009C4C761D9F0CD600F0BC6C /* LaunchScreen.storyboard in Resources */, 009C4C731D9F0CD600F0BC6C /* Assets.xcassets in Resources */, - 009C4C711D9F0CD600F0BC6C /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -259,6 +356,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3DAEE98E2BA26EE00069147E /* MockData.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -276,8 +374,20 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3DAEE9822BA24A8C0069147E /* UIImageView+Extensions.swift in Sources */, + 3DC96E942BA12AD700CFCDE8 /* Tweet.swift in Sources */, + 3DC96E962BA12B1000CFCDE8 /* TimelineViewModel.swift in Sources */, + 3D687F3C2BA21F4000DBFA2D /* AppCoordinator.swift in Sources */, + 3DFEDDF42BA2A20A00E12E5B /* TweetCellView.swift in Sources */, + 3D687F352BA1630B00DBFA2D /* Date+Extensions.swift in Sources */, 009C4C6E1D9F0CD600F0BC6C /* TimelineViewController.swift in Sources */, + 3DC96E9B2BA12D1A00CFCDE8 /* TweetCell.swift in Sources */, + 3DC96E982BA12B7A00CFCDE8 /* TimelineService.swift in Sources */, 009C4C6C1D9F0CD600F0BC6C /* AppDelegate.swift in Sources */, + 3D687F402BA21F9700DBFA2D /* ThreadViewModel.swift in Sources */, + 3D687F3E2BA21F6000DBFA2D /* ThreadViewController.swift in Sources */, + 3D687F392BA2162300DBFA2D /* TextDetector.swift in Sources */, + 3D687F372BA1651D00DBFA2D /* Bundle+Extensions.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -285,7 +395,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 009C4C811D9F0CD600F0BC6C /* OpenTweetTests.swift in Sources */, + 3DAEE9882BA261920069147E /* BundleExtensionsTests.swift in Sources */, + 3DAEE9862BA25FD90069147E /* TextDetectorTests.swift in Sources */, + 3DAEE98C2BA266820069147E /* TimelineViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -313,14 +425,6 @@ /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ - 009C4C6F1D9F0CD600F0BC6C /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 009C4C701D9F0CD600F0BC6C /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; 009C4C741D9F0CD600F0BC6C /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( diff --git a/OpenTweet/AppDelegate.swift b/OpenTweet/AppDelegate.swift index ac68abf..4b7586e 100644 --- a/OpenTweet/AppDelegate.swift +++ b/OpenTweet/AppDelegate.swift @@ -10,37 +10,49 @@ import UIKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { - - var window: UIWindow? - - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. - return true - } - - func applicationWillResignActive(_ application: UIApplication) { - // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. - // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. - } - - func applicationDidEnterBackground(_ application: UIApplication) { - // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. - // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. - } - - func applicationWillEnterForeground(_ application: UIApplication) { - // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. - } - - func applicationDidBecomeActive(_ application: UIApplication) { - // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. - } - - func applicationWillTerminate(_ application: UIApplication) { - // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. - } - - + + var window: UIWindow? + var appCoordinator: AppCoordinator? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + window = UIWindow(frame: UIScreen.main.bounds) + + let timelineService = TimelineServiceLocal() + let navigationController = UINavigationController() + appCoordinator = AppCoordinator( + navigationController: navigationController, + timelineService: timelineService + ) + appCoordinator?.start() + window?.rootViewController = navigationController + window?.makeKeyAndVisible() + + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + } diff --git a/OpenTweet/Base.lproj/Main.storyboard b/OpenTweet/Base.lproj/Main.storyboard deleted file mode 100644 index f8b7281..0000000 --- a/OpenTweet/Base.lproj/Main.storyboard +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/OpenTweet/Coordinators/AppCoordinator.swift b/OpenTweet/Coordinators/AppCoordinator.swift new file mode 100644 index 0000000..cc10126 --- /dev/null +++ b/OpenTweet/Coordinators/AppCoordinator.swift @@ -0,0 +1,34 @@ +// +// AppCoordinator.swift +// OpenTweet +// +// Created by David Auld on 2024-03-13. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import Foundation +import UIKit + +class AppCoordinator { + var navigationController: UINavigationController + + private let timelineService: TimelineService + + init(navigationController: UINavigationController, timelineService: TimelineService) { + self.navigationController = navigationController + self.timelineService = timelineService + } + + func start() { + let viewModel = TimelineViewModel(timelineService: timelineService) + let viewController = TimelineViewController(viewModel: viewModel) + viewModel.coordinator = self + navigationController.pushViewController(viewController, animated: false) + } + + func navigateToThread(thread: [Tweet]) { + let threadViewModel = ThreadViewModel(thread: thread) + let threadViewController = ThreadViewController(viewModel: threadViewModel) + navigationController.pushViewController(threadViewController, animated: true) + } +} diff --git a/OpenTweet/Extensions/Bundle+Extensions.swift b/OpenTweet/Extensions/Bundle+Extensions.swift new file mode 100644 index 0000000..69e5e7e --- /dev/null +++ b/OpenTweet/Extensions/Bundle+Extensions.swift @@ -0,0 +1,38 @@ +// +// Bundle+Extensions.swift +// OpenTweet +// +// Created by David Auld on 2024-03-12. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import Foundation +import Combine + +extension Bundle { + func readFile(file: String) -> AnyPublisher { + self.url(forResource: file, withExtension: nil) + .publisher + .tryMap { string in + guard let data = try? Data(contentsOf: string) else { + throw URLError(.cannotDecodeContentData) + } + return data + } + .mapError { error in + return error + } + .eraseToAnyPublisher() + } + + func decodable(fileName: String) -> AnyPublisher { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return readFile(file: fileName) + .decode(type: T.self, decoder: decoder) + .mapError { error in + return error + } + .eraseToAnyPublisher() + } +} diff --git a/OpenTweet/Extensions/Date+Extensions.swift b/OpenTweet/Extensions/Date+Extensions.swift new file mode 100644 index 0000000..b625146 --- /dev/null +++ b/OpenTweet/Extensions/Date+Extensions.swift @@ -0,0 +1,17 @@ +// +// Date+Extensions.swift +// OpenTweet +// +// Created by David Auld on 2024-03-12. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import Foundation + +extension Date { + func timelineTimestamp() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "MMM d, yyyy 'at' h:mm a" + return formatter.string(from: self) + } +} diff --git a/OpenTweet/Extensions/UIImageView+Extensions.swift b/OpenTweet/Extensions/UIImageView+Extensions.swift new file mode 100644 index 0000000..d583802 --- /dev/null +++ b/OpenTweet/Extensions/UIImageView+Extensions.swift @@ -0,0 +1,36 @@ +// +// UIImageView+Extensions.swift +// OpenTweet +// +// Created by David Auld on 2024-03-13. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import Foundation +import UIKit + +let imageCache = NSCache() + +extension UIImageView { + func loadImage(from url: URL, hitCache: Bool = true) { + self.image = nil + + if hitCache, let cachedImage = imageCache.object(forKey: url.absoluteString as NSString) { + self.image = cachedImage + return + } + + URLSession.shared.dataTask(with: url) { data, response, error in + guard + let httpURLResponse = response as? HTTPURLResponse, httpURLResponse.statusCode == 200, + let mimeType = response?.mimeType, mimeType.hasPrefix("image"), + let data = data, error == nil, + let image = UIImage(data: data) + else { return } + DispatchQueue.main.async() { [weak self] in + imageCache.setObject(image, forKey: url.absoluteString as NSString) + self?.image = image + } + }.resume() + } +} diff --git a/OpenTweet/Info.plist b/OpenTweet/Info.plist index d052473..c12df3b 100644 --- a/OpenTweet/Info.plist +++ b/OpenTweet/Info.plist @@ -22,8 +22,6 @@ UILaunchStoryboardName LaunchScreen - UIMainStoryboardFile - Main UIRequiredDeviceCapabilities armv7 diff --git a/OpenTweet/Models/Tweet.swift b/OpenTweet/Models/Tweet.swift new file mode 100644 index 0000000..36bcf02 --- /dev/null +++ b/OpenTweet/Models/Tweet.swift @@ -0,0 +1,44 @@ +// +// Tweet.swift +// OpenTweet +// +// Created by David Auld on 2024-03-12. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import Foundation + +final class Tweet: Hashable, Codable { + let id: String + let author: String + let content: String + let avatar: String? + let date: Date + let inReplyTo: String? + + var parentTweet: Tweet? + var replies: [Tweet]? + + init(id: String, author: String, content: String, avatar: String?, date: Date, inReplyTo: String?, parentTweet: Tweet? = nil, replies: [Tweet]? = nil) { + self.id = id + self.author = author + self.content = content + self.avatar = avatar + self.date = date + self.inReplyTo = inReplyTo + self.parentTweet = parentTweet + self.replies = replies + } + + static func == (lhs: Tweet, rhs: Tweet) -> Bool { + return lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +struct Timeline: Codable { + let timeline: [Tweet] +} diff --git a/OpenTweet/Services/TimelineService.swift b/OpenTweet/Services/TimelineService.swift new file mode 100644 index 0000000..69f4c58 --- /dev/null +++ b/OpenTweet/Services/TimelineService.swift @@ -0,0 +1,58 @@ +// +// TimelineService.swift +// OpenTweet +// +// Created by David Auld on 2024-03-12. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import Foundation +import Combine + +protocol TimelineService { + func fetchTimeline() -> AnyPublisher +} + +class TimelineServiceLocal: TimelineService { + func fetchTimeline() -> AnyPublisher { + Bundle.main.decodable(fileName: "timeline.json") + } +} + +#if DEBUG +class MockTimelineService: TimelineService { + let throwFailure: Bool + let mockDelay: Bool + let mockTimeline: Timeline + + static let defaultTweets = Timeline(timeline: [ + mockTweet(messageNumber: 1), + mockTweet(messageNumber: 2), + ]) + + init(throwFailure: Bool = false, mockDelay: Bool = false, mockTimeline: Timeline? = nil) { + self.throwFailure = throwFailure + self.mockDelay = mockDelay + self.mockTimeline = mockTimeline ?? MockTimelineService.defaultTweets + } + + func fetchTimeline() -> AnyPublisher { + if throwFailure { + return Fail(error: URLError(.cannotDecodeContentData)) + .eraseToAnyPublisher() + } else { + return Just(mockTimeline) + .delay(for: mockDelay ? 5 : 0, scheduler: RunLoop.main) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + } + + static func mockTweet(messageNumber: Int, isShortMessage: Bool = false) -> Tweet { + let id = String(messageNumber) + let author = "mockUser" + String(Int.random(in: 0...messageNumber)) + let content = isShortMessage ? "Really short message" : "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s." + return Tweet(id: id, author: "@\(author)", content: content, avatar: nil, date: Date(), inReplyTo: nil) + } +} +#endif diff --git a/OpenTweet/TimelineViewController.swift b/OpenTweet/TimelineViewController.swift deleted file mode 100644 index f96a784..0000000 --- a/OpenTweet/TimelineViewController.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// ViewController.swift -// OpenTweet -// -// Created by Olivier Larivain on 9/30/16. -// Copyright © 2016 OpenTable, Inc. All rights reserved. -// - -import UIKit - -class TimelineViewController: UIViewController { - - override func viewDidLoad() { - super.viewDidLoad() - // Do any additional setup after loading the view, typically from a nib. - } - -} - diff --git a/OpenTweet/Utilities/TextDetector.swift b/OpenTweet/Utilities/TextDetector.swift new file mode 100644 index 0000000..c9d2099 --- /dev/null +++ b/OpenTweet/Utilities/TextDetector.swift @@ -0,0 +1,38 @@ +// +// TextDetector.swift +// OpenTweet +// +// Created by David Auld on 2024-03-13. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import Foundation +import UIKit + +protocol TextPatternDetector { + func matches(in string: String) -> [NSTextCheckingResult] +} + +struct MentionDetector: TextPatternDetector { + func matches(in string: String) -> [NSTextCheckingResult] { + let pattern = "@\\w+" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + return regex.matches(in: string, range: NSRange(location: 0, length: string.utf16.count)) + } +} + +struct LinkDetector: TextPatternDetector { + func matches(in string: String) -> [NSTextCheckingResult] { + guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else { return [] } + return detector.matches(in: string, range: NSRange(location: 0, length: string.utf16.count)) + } +} + +struct AttributedStringBuilder { + var attributedString: NSMutableAttributedString + var detectors: [TextPatternDetector] = [MentionDetector(), LinkDetector()] + + init(string: String) { + attributedString = NSMutableAttributedString(string: string) + } +} diff --git a/OpenTweet/ViewControllers/Cells/TweetCell.swift b/OpenTweet/ViewControllers/Cells/TweetCell.swift new file mode 100644 index 0000000..d38bb89 --- /dev/null +++ b/OpenTweet/ViewControllers/Cells/TweetCell.swift @@ -0,0 +1,190 @@ +// +// TweetCell.swift +// OpenTweet +// +// Created by David Auld on 2024-03-12. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import Foundation +import UIKit + +class TweetCell: UICollectionViewCell { + static let identifier: String = "TweetCell" + + let view = TweetView() + + override var isHighlighted: Bool { + didSet { + updateHighlightedState() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: topAnchor, constant: 0), + view.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0), + view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0), + view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + view.reset() + } + + func updateHighlightedState() { + UIView.animate(withDuration: 0.2) { [weak self] in + guard let self else { return } + if self.isHighlighted { + self.view.backgroundColor = .lightGray + } else { + self.view.backgroundColor = .secondarySystemBackground + } + } + } + + func configure(tweet: Tweet) { + view.configure(tweet: tweet) + } +} + +class TweetView: UIView { + private var authorLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.font = UIFont.preferredFont(forTextStyle: .body) + label.numberOfLines = 1 + label.lineBreakMode = .byWordWrapping + label.textColor = UIColor.label + return label + }() + + private var contentLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .body) + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + label.textColor = UIColor.secondaryLabel + return label + }() + + private var timestampLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .footnote) + label.numberOfLines = 1 + label.lineBreakMode = .byWordWrapping + label.textColor = UIColor.tertiaryLabel + return label + }() + + private var avatarImageView: UIImageView = { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.backgroundColor = .gray + imageView.layer.cornerRadius = 20 + imageView.image = UIImage(systemName: "person.fill") + return imageView + }() + + private lazy var stackView: UIStackView = { + let stack = UIStackView(arrangedSubviews: [authorLabel, timestampLabel, contentLabel]) + stack.axis = .vertical + stack.alignment = .fill + stack.spacing = 5.0 + stack.translatesAutoresizingMaskIntoConstraints = false + return stack + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + self.backgroundColor = .secondarySystemBackground + self.layer.cornerRadius = 12 + self.layer.shadowRadius = 2 + self.layer.shadowOpacity = 0.1 + self.layer.shadowOffset = CGSize(width: 0, height: 4) + self.layer.masksToBounds = false + + self.addSubview(avatarImageView) + self.addSubview(stackView) + + NSLayoutConstraint.activate([ + avatarImageView.topAnchor.constraint(equalTo: topAnchor, constant: 10), + avatarImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + avatarImageView.widthAnchor.constraint(equalToConstant: 40), + avatarImageView.heightAnchor.constraint(equalToConstant: 40), + avatarImageView.bottomAnchor.constraint(lessThanOrEqualTo: stackView.bottomAnchor, constant: 0), + stackView.topAnchor.constraint(equalTo: topAnchor, constant: 10), + stackView.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 10), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10) + ]) + } + + override func layoutSubviews() { + super.layoutSubviews() + + let shadowPath = UIBezierPath(roundedRect: self.bounds, cornerRadius: self.layer.cornerRadius).cgPath + + self.layer.shadowPath = shadowPath + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(tweet: Tweet) { + authorLabel.text = tweet.author + timestampLabel.text = tweet.date.timelineTimestamp() + contentLabel.attributedText = styledAttributedString(for: tweet.content) + if let avatar = tweet.avatar, let avatarUrl = URL(string: avatar) { + avatarImageView.loadImage(from: avatarUrl) + } else { + avatarImageView.image = .init(systemName: "person.fill") + avatarImageView.tintColor = hashStringToColor(tweet.author) + } + } + + func reset() { + avatarImageView.image = nil + avatarImageView.tintColor = nil + } + + private func styledAttributedString(for content: String) -> NSAttributedString { + let stringBuilder = AttributedStringBuilder(string: content) + + let detectors: [(detector: TextPatternDetector, color: UIColor)] = [ + (MentionDetector(), .systemBlue), + (LinkDetector(), .systemBlue) + ] + + for (detector, color) in detectors { + let matches = detector.matches(in: content) + matches.forEach { match in + stringBuilder.attributedString.addAttributes([.foregroundColor: color], range: match.range) + } + } + + return stringBuilder.attributedString + } + + private func hashStringToColor(_ input: String) -> UIColor { + let scaleFactor = 0.6 // Adjust for darker (0) to lighter (1) colors + let hash = input.hashValue + let red = CGFloat((hash & 0xFF0000) >> 16) / 255.0 * scaleFactor + let green = CGFloat((hash & 0x00FF00) >> 8) / 255.0 * scaleFactor + let blue = CGFloat(hash & 0x0000FF) / 255.0 * scaleFactor + return UIColor(red: red, green: green, blue: blue, alpha: 1.0) + } +} diff --git a/OpenTweet/ViewControllers/Cells/TweetCellView.swift b/OpenTweet/ViewControllers/Cells/TweetCellView.swift new file mode 100644 index 0000000..3193a4a --- /dev/null +++ b/OpenTweet/ViewControllers/Cells/TweetCellView.swift @@ -0,0 +1,67 @@ +// +// TweetCellView.swift +// OpenTweet +// +// Created by David Auld on 2024-03-13. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import Foundation +import SwiftUI + +struct TweetCellView: View { + var tweet: Tweet + + init(tweet: Tweet) { + self.tweet = tweet + } + + var body: some View { + HStack(alignment: .top) { + Image(systemName: "person.fill") + .resizable() + .aspectRatio(contentMode: .fill) + .background(Color.blue) + .overlay( + Circle() + .stroke(.white, lineWidth: 2) + ) + .clipShape(Circle()) + .frame(width: 40, height: 40) + + VStack(alignment: .leading) { + Text(tweet.author) + .font(.headline) + Text(tweet.date.timelineTimestamp()) + .font(.footnote) + Text(tweet.content) + .font(.body) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.all, 20) + .background(container) + .frame(maxWidth: .infinity) + } + + var container: some View { + Rectangle() + .fill(Color.secondary) + .cornerRadius(12) + .shadow( + color: Color.gray.opacity(0.7), + radius: 8, + x: 0, + y: 0 + ) + } +} + +#if DEBUG +struct TweetCellView_Previews: PreviewProvider { + static var previews: some View { + TweetCellView(tweet: MockTimelineService.mockTweet(messageNumber: 123, isShortMessage: true)) + .padding(10) + } +} +#endif diff --git a/OpenTweet/ViewControllers/ThreadViewController.swift b/OpenTweet/ViewControllers/ThreadViewController.swift new file mode 100644 index 0000000..15da647 --- /dev/null +++ b/OpenTweet/ViewControllers/ThreadViewController.swift @@ -0,0 +1,124 @@ +// +// ThreadViewController.swift +// OpenTweet +// +// Created by David Auld on 2024-03-13. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import UIKit +import Combine + +class ThreadViewController: UIViewController { + + private var viewModel: ThreadViewModel + private var dataSource: UICollectionViewDiffableDataSource? + private var subscriptions = Set() + + private let collectionView: UICollectionView = { + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: createCollectionViewLayout()) + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.register(TweetCell.self, forCellWithReuseIdentifier: TweetCell.identifier) + collectionView.backgroundColor = .clear + return collectionView + }() + + private let activityIndicator: UIActivityIndicatorView = { + let activityIndicator = UIActivityIndicatorView(style: .medium) + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + activityIndicator.isHidden = false + activityIndicator.startAnimating() + return activityIndicator + }() + + init(viewModel: ThreadViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = "Thread" + + view.backgroundColor = .systemBackground + view.addSubview(collectionView) + view.addSubview(activityIndicator) + + setupConstraints() + setupObservers() + setupDataSource() + + viewModel.fetchThread() + } +} + +private extension ThreadViewController { + func setupConstraints() { + NSLayoutConstraint.activate([ + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } + + func setupObservers() { + viewModel.$state + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + switch state { + case .loading: + self?.activityIndicator.isHidden = false + case .success(let tweets): + self?.activityIndicator.isHidden = true + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(tweets) + self?.dataSource?.apply(snapshot, animatingDifferences: true) + case .error(let error): + self?.activityIndicator.isHidden = true + print(error) + //TODO - show error + default: + break + } + } + .store(in: &subscriptions) + } + + func setupDataSource() { + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView, + cellProvider: { collectionView, indexPath, itemIdentifier in + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: TweetCell.identifier, + for: indexPath + ) as? TweetCell else { + return UICollectionViewCell() + } + cell.configure(tweet: itemIdentifier) + return cell + }) + collectionView.dataSource = dataSource + } + + static func createCollectionViewLayout() -> UICollectionViewLayout { + let estimatedHeight: CGFloat = 120 + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .estimated(estimatedHeight) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item]) + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 8 + section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8) + return UICollectionViewCompositionalLayout(section: section) + } +} diff --git a/OpenTweet/ViewControllers/ThreadViewModel.swift b/OpenTweet/ViewControllers/ThreadViewModel.swift new file mode 100644 index 0000000..b7d59f3 --- /dev/null +++ b/OpenTweet/ViewControllers/ThreadViewModel.swift @@ -0,0 +1,29 @@ +// +// ThreadViewModel.swift +// OpenTweet +// +// Created by David Auld on 2024-03-13. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import Foundation + +class ThreadViewModel: ObservableObject { + enum State { + case idle + case loading + case success(tweets: [Tweet]) + case error(Error) + } + + @Published var state: State = .idle + @Published var thread: [Tweet] + + init(thread: [Tweet]) { + self.thread = thread + } + + func fetchThread() { + state = .success(tweets: thread) + } +} diff --git a/OpenTweet/ViewControllers/TimelineViewController.swift b/OpenTweet/ViewControllers/TimelineViewController.swift new file mode 100644 index 0000000..6b48436 --- /dev/null +++ b/OpenTweet/ViewControllers/TimelineViewController.swift @@ -0,0 +1,138 @@ +// +// ViewController.swift +// OpenTweet +// +// Created by Olivier Larivain on 9/30/16. +// Copyright © 2016 OpenTable, Inc. All rights reserved. +// + +import UIKit +import Combine + +class TimelineViewController: UIViewController { + + private var viewModel: TimelineViewModel + private var dataSource: UICollectionViewDiffableDataSource? + private var subscriptions = Set() + + private let collectionView: UICollectionView = { + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: createCollectionViewLayout()) + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.register(TweetCell.self, forCellWithReuseIdentifier: TweetCell.identifier) + collectionView.backgroundColor = .clear + return collectionView + }() + + private let activityIndicator: UIActivityIndicatorView = { + let activityIndicator = UIActivityIndicatorView(style: .medium) + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + activityIndicator.isHidden = false + activityIndicator.startAnimating() + return activityIndicator + }() + + init(viewModel: TimelineViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = "OpenTweet" + + view.backgroundColor = .systemBackground + view.addSubview(collectionView) + view.addSubview(activityIndicator) + + setupConstraints() + setupObservers() + setupDataSource() + + collectionView.delegate = self + viewModel.fetchTimeline() + } +} + +private extension TimelineViewController { + func setupConstraints() { + NSLayoutConstraint.activate([ + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } + + func setupObservers() { + viewModel.$state + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + switch state { + case .loading: + self?.activityIndicator.isHidden = false + case .success(let tweets): + self?.activityIndicator.isHidden = true + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(tweets) + self?.dataSource?.apply(snapshot, animatingDifferences: true) + case .error(let error): + self?.activityIndicator.isHidden = true + print(error) + //TODO - show error + default: + break + } + } + .store(in: &subscriptions) + } + + func setupDataSource() { + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView, + cellProvider: { collectionView, indexPath, itemIdentifier in + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: TweetCell.identifier, + for: indexPath + ) as? TweetCell else { + return UICollectionViewCell() + } + cell.configure(tweet: itemIdentifier) + return cell + }) + collectionView.dataSource = dataSource + } + + static func createCollectionViewLayout() -> UICollectionViewLayout { + let estimatedHeight: CGFloat = 120 + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .estimated(estimatedHeight) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item]) + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 8 + section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8) + return UICollectionViewCompositionalLayout(section: section) + } +} + +extension TimelineViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let tweet = self.dataSource?.snapshot().itemIdentifiers[indexPath.item], + let replies = tweet.replies else { return } + + // Construct thread, adding parent if it exists, tweet itself, then the replies + var thread: [Tweet] = [tweet.parentTweet, tweet].compactMap({ $0 }) + thread += replies + + viewModel.navigateToThread(thread: thread) + } +} diff --git a/OpenTweet/ViewControllers/TimelineViewModel.swift b/OpenTweet/ViewControllers/TimelineViewModel.swift new file mode 100644 index 0000000..461a619 --- /dev/null +++ b/OpenTweet/ViewControllers/TimelineViewModel.swift @@ -0,0 +1,62 @@ +// +// TimelineViewModel.swift +// OpenTweet +// +// Created by David Auld on 2024-03-12. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import Foundation +import Combine + +class TimelineViewModel: ObservableObject { + enum State: Equatable { + case idle + case loading + case success(tweets: [Tweet]) + case error(TimelineError) + } + + enum TimelineError: Error { + case decodingError + } + + @Published var state: State = .idle + + weak var coordinator: AppCoordinator? + + private var timelineService: TimelineService + private var subscriptions = Set() + + init(timelineService: TimelineService) { + self.timelineService = timelineService + } + + func fetchTimeline() { + state = .loading + timelineService.fetchTimeline() + .receive(on: RunLoop.main) + .sink { [weak self] completion in + switch completion { + case .failure(_): + self?.state = .error(TimelineError.decodingError) + case .finished: + break + } + } receiveValue: { [weak self] data in + // Map thread information to tweets + data.timeline.forEach { tweet in + tweet.parentTweet = data.timeline.first(where: { $0.id == tweet.inReplyTo }) + tweet.replies = data.timeline.filter { $0.inReplyTo == tweet.id } + tweet.replies?.sort { $0.date < $1.date } + } + + self?.state = .success(tweets: data.timeline.sorted { $0.date < $1.date }) + } + .store(in: &subscriptions) + } + + func navigateToThread(thread: [Tweet]) { + coordinator?.navigateToThread(thread: thread) + } +} diff --git a/OpenTweetTests/BundleExtensionsTests.swift b/OpenTweetTests/BundleExtensionsTests.swift new file mode 100644 index 0000000..49fd321 --- /dev/null +++ b/OpenTweetTests/BundleExtensionsTests.swift @@ -0,0 +1,62 @@ +// +// BundleTests.swift +// OpenTweet +// +// Created by David Auld on 2024-03-13. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import Combine +import XCTest +@testable import OpenTweet + +class BundleExtensionsTests: XCTestCase { + + struct MockData: Decodable { + let id: Int + let name: String + let date: Date + } + + var cancellables: Set = [] + + override func tearDown() { + cancellables.removeAll() + } + + func test_readFile() { + let expectation = XCTestExpectation(description: "Load data from file") + + Bundle(for: type(of: self)).readFile(file: "MockData.json") + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("Error loading file: \(error)") + } + }, receiveValue: { data in + XCTAssertNotNil(data, "Data should not be nil") + expectation.fulfill() + }) + .store(in: &cancellables) + + wait(for: [expectation], timeout: 5.0) + } + + func test_decodable() { + let expectation = XCTestExpectation(description: "Decode JSON to Model") + let bundle = Bundle(for: type(of: self)) + let decodable: AnyPublisher = bundle.decodable(fileName: "MockData.json") + + decodable + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("Error decoding file: \(error)") + } + }, receiveValue: { mockData in + XCTAssertEqual(mockData.name, "Test Item") + expectation.fulfill() + }) + .store(in: &cancellables) + + wait(for: [expectation], timeout: 5.0) + } +} diff --git a/OpenTweetTests/MockData.json b/OpenTweetTests/MockData.json new file mode 100644 index 0000000..a5d28cf --- /dev/null +++ b/OpenTweetTests/MockData.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "name": "Test Item", + "date": "2024-03-12T00:00:00Z" +} diff --git a/OpenTweetTests/OpenTweetTests.swift b/OpenTweetTests/OpenTweetTests.swift deleted file mode 100644 index 41aec9d..0000000 --- a/OpenTweetTests/OpenTweetTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// OpenTweetTests.swift -// OpenTweetTests -// -// Created by Olivier Larivain on 9/30/16. -// Copyright © 2016 OpenTable, Inc. All rights reserved. -// - -import XCTest -@testable import OpenTweet - -class OpenTweetTests: XCTestCase { - - override func setUp() { - super.setUp() - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDown() { - // Put teardown code here. This method is called after the invocation of each test method in the class. - super.tearDown() - } - - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - func testPerformanceExample() { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - -} diff --git a/OpenTweetTests/TextDetectorTests.swift b/OpenTweetTests/TextDetectorTests.swift new file mode 100644 index 0000000..150c13b --- /dev/null +++ b/OpenTweetTests/TextDetectorTests.swift @@ -0,0 +1,48 @@ +// +// TextDetector.swift +// OpenTweet +// +// Created by David Auld on 2024-03-13. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import XCTest +@testable import OpenTweet + +class TextDetectorTests: XCTestCase { + func test_mentionDetector_singleMention() { + let detector = MentionDetector() + let matches = detector.matches(in: "Hello @world") + XCTAssertEqual(matches.count, 1) + } + + func test_mentionsDetector_multipleMentions() { + let detector = MentionDetector() + let matches = detector.matches(in: "Hello @world @hi @this is #me") + XCTAssertEqual(matches.count, 3) + } + + func test_mentionsDetector_rangeOfMention() { + let detector = MentionDetector() + let matches = detector.matches(in: "Hello @world") + XCTAssertEqual(matches.first?.range, NSRange(location: 6, length: 6)) + } + + func test_linkDetector_singleLink() { + let detector = LinkDetector() + let matches = detector.matches(in: "Hello https://world.com") + XCTAssertEqual(matches.count, 1) + } + + func test_linkDetector_multipleLinks() { + let detector = LinkDetector() + let matches = detector.matches(in: "Hello https://world.com https://www.big.com www.earth.com") + XCTAssertEqual(matches.count, 3) + } + + func test_linkDetector_rangeOfLink() { + let detector = LinkDetector() + let matches = detector.matches(in: "Hello https://world.com") + XCTAssertEqual(matches.first?.range, NSRange(location: 6, length: 17)) + } +} diff --git a/OpenTweetTests/TimelineViewModel.swift b/OpenTweetTests/TimelineViewModel.swift new file mode 100644 index 0000000..09791e9 --- /dev/null +++ b/OpenTweetTests/TimelineViewModel.swift @@ -0,0 +1,89 @@ +// +// TimelineViewModel.swift +// OpenTweet +// +// Created by David Auld on 2024-03-12. +// Copyright © 2024 OpenTable, Inc. All rights reserved. +// + +import Combine +import XCTest +@testable import OpenTweet + +class TimelineViewModelTests: XCTestCase { + var subscriptions: Set! + + override func setUp() { + subscriptions = Set() + } + + override func tearDown() { + subscriptions = nil + super.tearDown() + } + + func test_fetchTimeline() { + let expectation = XCTestExpectation(description: "Fetch timeline") + let mockTimelineService = MockTimelineService(throwFailure: false) + let viewModel = TimelineViewModel(timelineService: mockTimelineService) + viewModel.$state + .dropFirst() + .sink { state in + switch state { + case .success(let tweets): + XCTAssertNotNil(tweets) + expectation.fulfill() + default: + break + } + } + .store(in: &subscriptions) + + viewModel.fetchTimeline() + wait(for: [expectation], timeout: 5) + } + + func test_idleState() { + let mockTimelineService = MockTimelineService(throwFailure: false) + let viewModel = TimelineViewModel(timelineService: mockTimelineService) + XCTAssertEqual(viewModel.state, .idle) + } + + func test_loadingState() { + let mockTimelineService = MockTimelineService(throwFailure: false, mockDelay: true) + let viewModel = TimelineViewModel(timelineService: mockTimelineService) + XCTAssertEqual(viewModel.state, .idle) + viewModel.fetchTimeline() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + XCTAssertEqual(viewModel.state, .loading) + } + } + + func test_successState() { + let timeline = Timeline(timeline: [ + MockTimelineService.mockTweet(messageNumber: 1), + MockTimelineService.mockTweet(messageNumber: 2), + MockTimelineService.mockTweet(messageNumber: 3), + ]) + let dataService = MockTimelineService(mockTimeline: timeline) + let viewModel = TimelineViewModel(timelineService: dataService) + XCTAssertEqual(viewModel.state, .idle) + + viewModel.fetchTimeline() + // Simulate the asynchronous data loading + DispatchQueue.main.async { + XCTAssertEqual(viewModel.state, .success(tweets: timeline.timeline)) + } + } + + func test_errorState() { + let mockTimelineService = MockTimelineService(throwFailure: true) + let viewModel = TimelineViewModel(timelineService: mockTimelineService) + + XCTAssertEqual(viewModel.state, .idle) + viewModel.fetchTimeline() + DispatchQueue.main.async { + XCTAssertEqual(viewModel.state, .error(.decodingError)) + } + } +} diff --git a/OpenTweetUITests/OpenTweetUITests.swift b/OpenTweetUITests/OpenTweetUITests.swift index afe71a4..8d8e20e 100644 --- a/OpenTweetUITests/OpenTweetUITests.swift +++ b/OpenTweetUITests/OpenTweetUITests.swift @@ -28,9 +28,12 @@ class OpenTweetUITests: XCTestCase { super.tearDown() } - func testExample() { - // Use recording to get started writing UI tests. - // Use XCTAssert and related functions to verify your tests produce the correct results. + func testNavigationToThreads() { + let app = XCUIApplication() + let collectionViewsQuery = app.collectionViews + collectionViewsQuery.cells.firstMatch.tap() + + let detailsView = app.navigationBars["Thread"] + XCTAssertTrue(detailsView.waitForExistence(timeout: 5)) } - }